diff --git a/pkg/git/checkout_branch.go b/pkg/git/checkout_branch.go index 439c926..4825162 100644 --- a/pkg/git/checkout_branch.go +++ b/pkg/git/checkout_branch.go @@ -7,24 +7,90 @@ 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. +// If origin/branch doesn't exist, it will create the branch from HEAD. func (g *realGit) CheckoutBranch(worktreePath, branch string) error { // First try to checkout the branch directly + output, err := g.tryCheckoutBranch(worktreePath, branch) + if err == nil { + return nil + } + + // If that fails, check if the branch exists locally + if branchExists, branchErr := g.BranchExists(worktreePath, branch); branchErr == nil && branchExists { + // Branch exists locally, try checkout again (might need ref refresh) + if _, err2 := g.tryCheckoutBranch(worktreePath, branch); err2 == nil { + return nil + } + } + + // Try to checkout from origin/branch + originOutput, err := g.tryCheckoutFromOrigin(worktreePath, branch) + if err == nil { + return nil + } + + // Fetch and try again + if g.FetchRemote(worktreePath, "origin") == nil { + if originOutput, err = g.tryCheckoutFromOrigin(worktreePath, branch); err == nil { + return nil + } + } + + // Final fallback: create branch from HEAD + headOutput, err3 := g.createBranchFromHead(worktreePath, branch) + if err3 != nil { + // Return comprehensive error with all attempted operations + errorMsg := fmt.Sprintf( + "git checkout failed: %v (command: git checkout %s, output: %s", + err, branch, string(output)) + if len(originOutput) > 0 { + errorMsg += fmt.Sprintf("; fallback: git checkout -b %s origin/%s, output: %s", branch, branch, string(originOutput)) + } + errorMsg += fmt.Sprintf("; final fallback: git checkout -b %s HEAD, output: %s)", branch, string(headOutput)) + return fmt.Errorf("%s", errorMsg) + } + return nil +} + +// tryCheckoutBranch attempts to checkout a branch directly. +func (g *realGit) tryCheckoutBranch(worktreePath, branch string) ([]byte, error) { cmd := exec.Command("git", "checkout", branch) cmd.Dir = worktreePath output, err := cmd.CombinedOutput() - if err == nil { - return nil + if err != nil { + return output, err + } + return output, nil +} + +// tryCheckoutFromOrigin attempts to checkout a branch from origin/branch if it exists. +func (g *realGit) tryCheckoutFromOrigin(worktreePath, branch string) ([]byte, error) { + remoteBranchExists, remoteErr := g.BranchExistsOnRemote(BranchExistsOnRemoteParams{ + RepoPath: worktreePath, + RemoteName: "origin", + Branch: branch, + }) + if remoteErr != nil || !remoteBranchExists { + return nil, fmt.Errorf("remote branch origin/%s does not exist", branch) } - // If that fails, try to checkout with tracking from origin - cmd = exec.Command("git", "checkout", "-b", branch, "origin/"+branch) + // origin/branch exists, create local branch tracking it + 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)) + output, err := cmd.CombinedOutput() + if err != nil { + return output, err } - return nil + return output, nil +} + +// createBranchFromHead creates a new branch from HEAD. +func (g *realGit) createBranchFromHead(worktreePath, branch string) ([]byte, error) { + cmd := exec.Command("git", "checkout", "-b", branch, "HEAD") + cmd.Dir = worktreePath + output, err := cmd.CombinedOutput() + if err != nil { + return output, err + } + return output, nil } diff --git a/pkg/git/checkout_branch_test.go b/pkg/git/checkout_branch_test.go index cb32281..62cecc2 100644 --- a/pkg/git/checkout_branch_test.go +++ b/pkg/git/checkout_branch_test.go @@ -35,10 +35,20 @@ func TestGit_CheckoutBranch(t *testing.T) { t.Fatalf("Expected no error checking out branch: %v", err) } - // Test checking out non-existent branch - err = git.CheckoutBranch(testWorktreePath, "non-existent-branch") - if err == nil { - t.Error("Expected error when checking out non-existent branch") + // Test checking out non-existent branch (should create from HEAD) + nonExistentBranch := "non-existent-branch-" + strings.ReplaceAll(t.Name(), "/", "-") + err = git.CheckoutBranch(testWorktreePath, nonExistentBranch) + if err != nil { + t.Errorf("Expected no error when checking out non-existent branch (should create from HEAD): %v", err) + } + + // Verify the branch was created + exists, err := git.BranchExists(testWorktreePath, nonExistentBranch) + if err != nil { + t.Errorf("Expected no error checking created branch existence: %v", err) + } + if !exists { + t.Errorf("Expected branch %s to exist after checkout", nonExistentBranch) } // Test in non-existent directory @@ -47,3 +57,87 @@ func TestGit_CheckoutBranch(t *testing.T) { t.Error("Expected error for non-existent directory") } } + +// TestGit_CheckoutBranch_FromHead tests checkout when branch doesn't exist locally or on remote +func TestGit_CheckoutBranch_FromHead(t *testing.T) { + git := NewGit() + _, cleanup := SetupTestRepo(t) + defer cleanup() + + // Create a unique branch for worktree creation to avoid conflicts + worktreeBaseBranch := "worktree-base-" + strings.ReplaceAll(t.Name(), "/", "-") + err := git.CreateBranch(".", worktreeBaseBranch) + if err != nil { + t.Fatalf("Expected no error creating base branch: %v", err) + } + + // Create a worktree without checkout + testWorktreePath := "test-checkout-head-worktree-" + strings.ReplaceAll(t.Name(), "/", "-") + testBranchName := "test-branch-from-head-" + strings.ReplaceAll(t.Name(), "/", "-") + + err = git.CreateWorktreeWithNoCheckout(".", testWorktreePath, worktreeBaseBranch) + if err != nil { + t.Fatalf("Expected no error creating worktree without checkout: %v", err) + } + defer os.RemoveAll(testWorktreePath) + + // Checkout a branch that doesn't exist (should create from HEAD) + err = git.CheckoutBranch(testWorktreePath, testBranchName) + if err != nil { + t.Fatalf("Expected no error checking out non-existent branch (should create from HEAD): %v", err) + } + + // Verify the branch was created + exists, err := git.BranchExists(testWorktreePath, testBranchName) + if err != nil { + t.Errorf("Expected no error checking created branch existence: %v", err) + } + if !exists { + t.Errorf("Expected branch %s to exist after checkout", testBranchName) + } +} + +// TestGit_CheckoutBranch_LocalBranchExists tests checkout when branch exists locally +func TestGit_CheckoutBranch_LocalBranchExists(t *testing.T) { + git := NewGit() + _, cleanup := SetupTestRepo(t) + defer cleanup() + + // Create a unique branch for worktree creation to avoid conflicts + worktreeBaseBranch := "worktree-base-local-" + strings.ReplaceAll(t.Name(), "/", "-") + err := git.CreateBranch(".", worktreeBaseBranch) + if err != nil { + t.Fatalf("Expected no error creating base branch: %v", err) + } + + // Create a test branch in main repo + testBranchName := "test-local-branch-" + strings.ReplaceAll(t.Name(), "/", "-") + err = git.CreateBranch(".", testBranchName) + if err != nil { + t.Fatalf("Expected no error creating branch: %v", err) + } + + // Create a worktree without checkout + testWorktreePath := "test-checkout-local-worktree-" + strings.ReplaceAll(t.Name(), "/", "-") + + err = git.CreateWorktreeWithNoCheckout(".", testWorktreePath, worktreeBaseBranch) + if err != nil { + t.Fatalf("Expected no error creating worktree without checkout: %v", err) + } + defer os.RemoveAll(testWorktreePath) + + // Checkout the branch that exists locally + err = git.CheckoutBranch(testWorktreePath, testBranchName) + if err != nil { + t.Fatalf("Expected no error checking out existing local branch: %v", err) + } + + // Verify we're on the correct branch + currentWorktreeBranch, err := git.GetCurrentBranch(testWorktreePath) + if err != nil { + t.Errorf("Expected no error getting current branch in worktree: %v", err) + } + if currentWorktreeBranch != testBranchName { + t.Errorf("Expected to be on branch %s, got %s", testBranchName, currentWorktreeBranch) + } +} diff --git a/test/worktree_create_test.go b/test/worktree_create_test.go index f0cc26e..69aaee7 100644 --- a/test/worktree_create_test.go +++ b/test/worktree_create_test.go @@ -1027,3 +1027,134 @@ func TestCreateWorktreeWorkspaceMode_RepositoryNames(t *testing.T) { assert.Contains(t, workspaceContentStr, `"name": "Hello-World"`, "Workspace file should contain Hello-World name") assert.Contains(t, workspaceContentStr, `"name": "Spoon-Knife"`, "Workspace file should contain Spoon-Knife name") } + +// TestCreateWorktreeBranchNotOnRemote_RepoMode tests creating a worktree for a branch that doesn't exist on the remote. +// This test reproduces and verifies the fix for the bug where checkout fails when trying to create from origin/branch +// that doesn't exist. The fix should create the branch from HEAD instead. +func TestCreateWorktreeBranchNotOnRemote_RepoMode(t *testing.T) { + setup := setupTestEnvironment(t) + defer cleanupTestEnvironment(t, setup) + + // Create a test Git repository + createTestGitRepo(t, setup.RepoPath) + + // Create a worktree for a branch that doesn't exist on the remote (like deploy-v0-0-67 from the bug report) + branchName := "deploy-v0-0-67" + err := createWorktree(t, setup, branchName) + require.NoError(t, err, "Command should succeed even though branch doesn't exist on remote") + + // Verify the status.yaml file was created and updated + status := readStatusFile(t, setup.StatusPath) + require.NotNil(t, status.Repositories, "Status file should have repositories section") + require.Len(t, status.Repositories, 1, "Should have one repository entry") + + // Get the first repository from the map + var repoURL string + var repo Repository + for url, r := range status.Repositories { + repoURL = url + repo = r + break + } + + assert.NotEmpty(t, repoURL, "Repository URL should be set") + assert.NotEmpty(t, repo.Path, "Repository path should be set") + + // Check that the repository has the worktree + require.True(t, len(repo.Worktrees) > 0, "Repository should have at least one worktree") + + // Check that the worktree for our branch exists + var foundWorktree bool + for _, worktree := range repo.Worktrees { + if worktree.Branch == branchName { + foundWorktree = true + break + } + } + assert.True(t, foundWorktree, "Should have worktree for %s", branchName) + + // Verify the worktree exists in the .cm directory + assertWorktreeExists(t, setup, branchName) + + // Verify the worktree is properly linked in the original repository + assertWorktreeInRepo(t, setup, branchName) + + // Verify the worktree is on the correct branch + worktreePath := findWorktreePath(t, setup, branchName) + require.NotEmpty(t, worktreePath, "Worktree path should be found") + + cmd := exec.Command("git", "branch", "--show-current") + cmd.Dir = worktreePath + output, err := cmd.Output() + require.NoError(t, err) + assert.Equal(t, branchName, strings.TrimSpace(string(output)), "Worktree should be on the correct branch") +} + +// TestCreateWorktreeBranchNotOnRemote_WorkspaceMode tests creating worktrees from workspace for branches that don't exist on the remote. +// This test verifies the fix works in workspace mode as well. +func TestCreateWorktreeBranchNotOnRemote_WorkspaceMode(t *testing.T) { + setup := setupTestEnvironment(t) + defer cleanupTestEnvironment(t, setup) + + // Create test repositories + repo1Path := filepath.Join(setup.CmPath, "Hello-World") + repo2Path := filepath.Join(setup.CmPath, "Spoon-Knife") + + err := os.MkdirAll(repo1Path, 0755) + require.NoError(t, err) + err = os.MkdirAll(repo2Path, 0755) + require.NoError(t, err) + + // Initialize Git repositories + createTestGitRepo(t, repo1Path) + createTestGitRepo(t, repo2Path) + + // Create CM instance + cmInstance, err := codemanager.NewCodeManager(codemanager.NewCodeManagerParams{ + Dependencies: createE2EDependencies(setup.ConfigPath). + WithConfig(config.NewManager(setup.ConfigPath)), + }) + require.NoError(t, err) + + // Create workspace + workspaceName := "test-workspace-branch-not-on-remote" + workspaceParams := codemanager.CreateWorkspaceParams{ + WorkspaceName: workspaceName, + Repositories: []string{repo1Path, repo2Path}, + } + err = cmInstance.CreateWorkspace(workspaceParams) + require.NoError(t, err) + + // Create worktrees from workspace for a branch that doesn't exist on remote + branchName := "deploy-v0-0-67" + err = cmInstance.CreateWorkTree(branchName, codemanager.CreateWorkTreeOpts{ + WorkspaceName: workspaceName, + }) + require.NoError(t, err, "Should succeed even though branch doesn't exist on remote") + + // Verify workspace file was created + workspaceFilePath := filepath.Join(setup.CmPath, "workspaces", workspaceName, branchName+".code-workspace") + assert.FileExists(t, workspaceFilePath, "Workspace file should be created") + + // Verify workspace file content + content, err := os.ReadFile(workspaceFilePath) + require.NoError(t, err) + assert.Contains(t, string(content), "github.com/octocat/Hello-World") + assert.Contains(t, string(content), "github.com/octocat/Spoon-Knife") + assert.Contains(t, string(content), "folders") + + // Verify worktrees were created for both repositories + status := readStatusFile(t, setup.StatusPath) + require.NotNil(t, status.Repositories, "Status file should have repositories section") + + // Count worktrees for the branch + worktreeCount := 0 + for _, repo := range status.Repositories { + for _, worktree := range repo.Worktrees { + if worktree.Branch == branchName { + worktreeCount++ + } + } + } + assert.Equal(t, 2, worktreeCount, "Should have worktrees for both repositories") +}