Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 77 additions & 11 deletions pkg/git/checkout_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
102 changes: 98 additions & 4 deletions pkg/git/checkout_branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
131 changes: 131 additions & 0 deletions test/worktree_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}