From edfec2db4f52a1cf0b69071c38413ff66e3c8d09 Mon Sep 17 00:00:00 2001 From: Louis FRADIN Date: Tue, 30 Dec 2025 17:34:40 +0100 Subject: [PATCH] fix: create worktrees for all workspace branches when adding repository Previously, when adding a repository to an existing workspace, worktrees were only created for branches that already existed in the new repository. If a branch didn't exist locally or on remote, worktree creation was skipped. This fix ensures that worktrees are created for ALL branches defined in workspace.Worktrees, even if they don't exist in the new repository. The worktree package's EnsureBranchExists will create branches from the default branch if they don't exist on remote. Changes: - Updated shouldSkipWorktreeCreation to always attempt worktree creation - Refactored branch status logging into separate logBranchStatus function - Updated E2E tests to expect worktrees for all branches Fixes the issue where worktrees were not created when adding repositories to workspaces with branches that don't exist in the new repository. --- pkg/code-manager/workspace_add.go | 126 +++-- pkg/code-manager/workspace_add_test.go | 152 +++--- test/workspace_add_test.go | 691 ++++++++++++++++++++++++- 3 files changed, 817 insertions(+), 152 deletions(-) diff --git a/pkg/code-manager/workspace_add.go b/pkg/code-manager/workspace_add.go index 8556d50..e67c698 100644 --- a/pkg/code-manager/workspace_add.go +++ b/pkg/code-manager/workspace_add.go @@ -65,10 +65,14 @@ func (c *realCodeManager) addRepositoryToWorkspace(params *AddRepositoryToWorksp params.WorkspaceName = workspaceName params.Repository = finalRepoURL - // Determine branches that have worktrees in ALL existing repositories (before adding new one) - branchesToCreate := c.getBranchesWithWorktreesInAllRepos(workspace, workspace.Repositories) + // Try to create worktrees for ALL branches in the workspace + // If a branch doesn't exist in the new repository, it will be skipped gracefully + branchesToCreate := workspace.Worktrees - c.VerbosePrint("Found %d branches with worktrees in all repositories: %v", len(branchesToCreate), branchesToCreate) + c.VerbosePrint( + "Will attempt to create worktrees for %d branches in workspace: %v", + len(branchesToCreate), branchesToCreate, + ) // Update workspace in status.yaml to include new repository workspace.Repositories = append(workspace.Repositories, finalRepoURL) @@ -76,7 +80,15 @@ func (c *realCodeManager) addRepositoryToWorkspace(params *AddRepositoryToWorksp return fmt.Errorf("%w: failed to update workspace: %w", ErrStatusUpdate, err) } - // Create worktrees for each branch that has worktrees in all repositories + // Update all existing workspace files to include the new repository + // This must happen before worktree creation so that all workspace files are updated, + // regardless of whether worktrees are created for all branches + if err := c.updateAllWorkspaceFilesForNewRepository(workspaceName, finalRepoURL, workspace.Worktrees); err != nil { + return fmt.Errorf("failed to update workspace files: %w", err) + } + + // Create worktrees for all branches in the workspace + // If a branch doesn't exist in the new repository, it will be skipped gracefully // Pass originalRepoPath to check for local branches that might not exist in cloned repository if err := c.createWorktreesForBranches(branchesToCreate, finalRepoURL, workspaceName, originalRepoPath); err != nil { return err @@ -302,62 +314,28 @@ func (c *realCodeManager) verifyAndCleanupWorktree(repoURL, branchName string) { } } -// getBranchesWithWorktreesInAllRepos returns branches that have worktrees in ALL existing repositories. -func (c *realCodeManager) getBranchesWithWorktreesInAllRepos( - workspace *status.Workspace, existingRepos []string, -) []string { - var branchesWithAllRepos []string - - // Safety check - if workspace == nil { - return branchesWithAllRepos - } - - // For each branch in workspace.Worktrees - for _, branchName := range workspace.Worktrees { - if c.branchHasWorktreesInAllRepos(branchName, existingRepos) { - branchesWithAllRepos = append(branchesWithAllRepos, branchName) - c.VerbosePrint(" ✓ Branch '%s' has worktrees in all repositories", branchName) - } - } - - return branchesWithAllRepos -} - -// branchHasWorktreesInAllRepos checks if a branch has worktrees in all given repositories. -func (c *realCodeManager) branchHasWorktreesInAllRepos(branchName string, existingRepos []string) bool { - // Check each repository in the list - for _, repoURL := range existingRepos { - // Get repository from status - repo, err := c.deps.StatusManager.GetRepository(repoURL) - if err != nil || repo == nil { - c.VerbosePrint(" ⚠ Skipping repository %s: %v", repoURL, err) - return false - } - - // Check if repository has a worktree for that branch - if !c.repositoryHasWorktreeForBranch(repo, branchName) { - c.VerbosePrint(" Branch '%s' missing in repository '%s'", branchName, repoURL) - return false - } - } - - return true -} - -// repositoryHasWorktreeForBranch checks if a repository has a worktree for the given branch. -func (c *realCodeManager) repositoryHasWorktreeForBranch(repo *status.Repository, branchName string) bool { - if repo.Worktrees == nil { - return false - } - - for _, worktree := range repo.Worktrees { - if worktree.Branch == branchName { - return true +// updateAllWorkspaceFilesForNewRepository updates all existing workspace files for a workspace +// to include a new repository. It iterates through all branches in workspace.Worktrees and +// updates each corresponding workspace file. +func (c *realCodeManager) updateAllWorkspaceFilesForNewRepository( + workspaceName, repoURL string, branches []string, +) error { + c.VerbosePrint("Updating all workspace files for workspace '%s' with new repository '%s'", workspaceName, repoURL) + + for _, branchName := range branches { + c.VerbosePrint(" Updating workspace file for branch '%s'", branchName) + if err := c.updateWorkspaceFileForNewRepository(workspaceName, branchName, repoURL); err != nil { + // Log error but continue with other branches + // Some workspace files might not exist yet, which is handled gracefully in updateWorkspaceFileForNewRepository + c.VerbosePrint(" ⚠ Failed to update workspace file for branch '%s': %v", branchName, err) + // Only return error if it's not a "file doesn't exist" case + // updateWorkspaceFileForNewRepository returns nil if file doesn't exist, so any error here is real + return fmt.Errorf("failed to update workspace file for branch '%s': %w", branchName, err) } } - return false + c.VerbosePrint(" ✓ Updated all workspace files for workspace '%s'", workspaceName) + return nil } // updateWorkspaceFileForNewRepository updates a workspace file to include a new repository. @@ -479,15 +457,17 @@ func (c *realCodeManager) checkExistingWorktree( return worktreePath, true } -// shouldSkipWorktreeCreation checks if worktree creation should be skipped because branch doesn't exist. -func (c *realCodeManager) shouldSkipWorktreeCreation( +// logBranchStatus logs the status of a branch for worktree creation. +// Worktree creation will always proceed because the worktree package's EnsureBranchExists +// will handle creating the branch from remote (if it exists) or from default branch (if it doesn't). +func (c *realCodeManager) logBranchStatus( repoURL, branchName, mainRepoPath, originalRepoPath string, -) bool { +) { // Check if branch exists in managed repository branchExists, checkErr := c.deps.Git.BranchExists(mainRepoPath, branchName) if checkErr == nil && branchExists { c.VerbosePrint(" Branch '%s' exists in repository '%s', proceeding with worktree creation", branchName, repoURL) - return false + return } // Check original repository if available (for local branches) @@ -498,7 +478,7 @@ func (c *realCodeManager) shouldSkipWorktreeCreation( " Branch '%s' exists in original repository '%s' (local branch), proceeding with worktree creation", branchName, originalRepoPath, ) - return false + return } } @@ -509,23 +489,33 @@ func (c *realCodeManager) shouldSkipWorktreeCreation( RemoteName: "origin", Branch: branchName, }) - if remoteErr == nil && !remoteExists { - // Branch doesn't exist on remote either - skip worktree creation + if remoteErr == nil && remoteExists { + // Branch exists on remote but not fetched yet - let repository mode fetch and create it + c.VerbosePrint(" Branch '%s' exists on remote but not fetched yet, repository mode will fetch it", branchName) + } else { + // Branch doesn't exist on remote - worktree creation will create it from default branch c.VerbosePrint( - " Branch '%s' does not exist locally or on remote in repository '%s', skipping worktree creation", + " Branch '%s' does not exist on remote in repository '%s', worktree creation will create it from default branch", branchName, repoURL, ) - return true } - // Branch exists on remote but not fetched yet - let repository mode fetch and create it - c.VerbosePrint(" Branch '%s' exists on remote but not fetched yet, repository mode will fetch it", branchName) } else { c.VerbosePrint( " Warning: Failed to check if branch '%s' exists: %v, will attempt creation anyway", branchName, checkErr, ) } +} +// shouldSkipWorktreeCreation checks if worktree creation should be skipped. +// Currently always returns false because worktree creation will handle creating +// branches that don't exist (from remote or default branch). +func (c *realCodeManager) shouldSkipWorktreeCreation( + repoURL, branchName, mainRepoPath, originalRepoPath string, +) bool { + c.logBranchStatus(repoURL, branchName, mainRepoPath, originalRepoPath) + // Always attempt worktree creation - the worktree package's EnsureBranchExists + // will handle creating the branch from remote (if it exists) or from default branch (if it doesn't) return false } diff --git a/pkg/code-manager/workspace_add_test.go b/pkg/code-manager/workspace_add_test.go index feebd37..39d987a 100644 --- a/pkg/code-manager/workspace_add_test.go +++ b/pkg/code-manager/workspace_add_test.go @@ -64,19 +64,27 @@ func TestAddRepositoryToWorkspace_Success(t *testing.T) { // normalizeRepositoryURL converts https://github.com/user/repo1.git -> github.com/user/repo1 mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) - // Mock getting existing repositories to check branches - existingRepoStatus := &status.Repository{ - Path: "/path/to/existing-repo", - Worktrees: map[string]status.WorktreeInfo{ - "origin:main": {Branch: "main", Remote: "origin"}, - "origin:feature": {Branch: "feature", Remote: "origin"}, - }, - } - mockStatus.EXPECT().GetRepository("github.com/user/existing-repo").Return(existingRepoStatus, nil).Times(2) // Called once per branch check - // Mock updating workspace in status mockStatus.EXPECT().UpdateWorkspace("test-workspace", gomock.Any()).Return(nil) + // Mock config for workspace file path construction + mockConfig := config.NewConfigManager("/test/config.yaml") + cm.deps = cm.deps.WithConfig(mockConfig) + + // NEW: updateAllWorkspaceFilesForNewRepository is called for ALL branches in workspace.Worktrees + // This happens BEFORE worktree creation + // For each branch (main and feature), it calls updateWorkspaceFileForNewRepository which: + // 1. Checks if workspace file exists (fs.Exists) + // 2. If exists, reads it (fs.ReadFile) and writes it back (fs.WriteFileAtomic) + // Branch "main" workspace file update (first call from updateAllWorkspaceFilesForNewRepository) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // Branch "feature" workspace file update (first call from updateAllWorkspaceFilesForNewRepository) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // Mock GetRepository call at the start of createWorktreesForBranches (for default branch check) mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -88,10 +96,6 @@ func TestAddRepositoryToWorkspace_Success(t *testing.T) { // 5. CreateWorktree // 6. BranchExists (verify after creation) - // Mock config for workspace file path construction - mockConfig := config.NewConfigManager("/test/config.yaml") - cm.deps = cm.deps.WithConfig(mockConfig) - // Branch "main" sequence // Inside createWorktreeForBranchInRepository: mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -101,9 +105,11 @@ func TestAddRepositoryToWorkspace_Success(t *testing.T) { mockRepo.EXPECT().CreateWorktree("main", gomock.Any()).Return("/repos/github.com/user/repo1/origin/main", nil) // verifyBranchExistsAfterCreation (inside createWorktreeForBranchInRepository): mockGit.EXPECT().BranchExists("/path/to/repo1", "main").Return(true, nil) - // Workspace file update for main branch + // Workspace file update for main branch (second call from createWorktreesForBranches) + // Note: Even though repository was added in first call, the path comparison might not match + // due to different path formats, so the function may write again (idempotent operation) mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) - mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[{"path":"/repos/github.com/user/repo1/origin/main","name":"repo1"}]}`), nil) mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) // verifyAndCleanupWorktree (after worktree creation in loop): mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -119,9 +125,11 @@ func TestAddRepositoryToWorkspace_Success(t *testing.T) { mockRepo.EXPECT().CreateWorktree("feature", gomock.Any()).Return("/repos/github.com/user/repo1/origin/feature", nil) // verifyBranchExistsAfterCreation (inside createWorktreeForBranchInRepository): mockGit.EXPECT().BranchExists("/path/to/repo1", "feature").Return(true, nil) - // Workspace file update for feature branch + // Workspace file update for feature branch (second call from createWorktreesForBranches) + // Note: Even though repository was added in first call, the path comparison might not match + // due to different path formats, so the function may write again (idempotent operation) mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) - mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[{"path":"/repos/github.com/user/repo1/origin/feature","name":"repo1"}]}`), nil) mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) // verifyAndCleanupWorktree (after worktree creation in loop): mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -293,20 +301,25 @@ func TestAddRepositoryToWorkspace_SomeBranchesWithAllRepos(t *testing.T) { // Mock repository exists in status check (using the normalized URL) mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) - // Mock getting existing repository - has worktrees for "main" and "feature", but not "develop" - existingRepoStatus := &status.Repository{ - Path: "/path/to/existing-repo", - Worktrees: map[string]status.WorktreeInfo{ - "origin:main": {Branch: "main", Remote: "origin"}, - "origin:feature": {Branch: "feature", Remote: "origin"}, - // No "develop" worktree - }, - } - mockStatus.EXPECT().GetRepository("github.com/user/existing-repo").Return(existingRepoStatus, nil).Times(3) // Called once per branch check - // Mock updating workspace in status mockStatus.EXPECT().UpdateWorkspace("test-workspace", gomock.Any()).Return(nil) + // NEW: updateAllWorkspaceFilesForNewRepository is called for ALL branches in workspace.Worktrees + // This happens BEFORE worktree creation + // For each branch (main, feature, develop), it calls updateWorkspaceFileForNewRepository + // Branch "main" workspace file update (first call) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // Branch "feature" workspace file update (first call) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // Branch "develop" workspace file update (first call - no worktree created for this branch) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // Mock GetRepository call at the start of createWorktreesForBranches (for default branch check) mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -317,9 +330,11 @@ func TestAddRepositoryToWorkspace_SomeBranchesWithAllRepos(t *testing.T) { mockGit.EXPECT().BranchExists("/path/to/repo1", "main").Return(true, nil) mockRepo.EXPECT().CreateWorktree("main", gomock.Any()).Return("/repos/github.com/user/repo1/origin/main", nil) mockGit.EXPECT().BranchExists("/path/to/repo1", "main").Return(true, nil) - // Workspace file update for main + // Workspace file update for main (second call from createWorktreesForBranches) + // Note: Even though repository was added in first call, the path comparison might not match + // due to different path formats, so the function may write again (idempotent operation) mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) - mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[{"path":"/repos/github.com/user/repo1/origin/main","name":"repo1"}]}`), nil) mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) // verifyAndCleanupWorktree for main mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -333,15 +348,38 @@ func TestAddRepositoryToWorkspace_SomeBranchesWithAllRepos(t *testing.T) { mockGit.EXPECT().BranchExists("/path/to/repo1", "feature").Return(true, nil) mockRepo.EXPECT().CreateWorktree("feature", gomock.Any()).Return("/repos/github.com/user/repo1/origin/feature", nil) mockGit.EXPECT().BranchExists("/path/to/repo1", "feature").Return(true, nil) - // Workspace file update for feature + // Workspace file update for feature (second call from createWorktreesForBranches) + // Note: Even though repository was added in first call, the path comparison might not match + // due to different path formats, so the function may write again (idempotent operation) mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) - mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[{"path":"/repos/github.com/user/repo1/origin/feature","name":"repo1"}]}`), nil) mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) // verifyAndCleanupWorktree for feature mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) mockGit.EXPECT().GetMainRepositoryPath("/path/to/repo1").Return("/path/to/repo1", nil) mockGit.EXPECT().BranchExists("/path/to/repo1", "feature").Return(true, nil) + // Branch "develop" sequence - branch doesn't exist, but worktree creation will create it from default branch + mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) + mockStatus.EXPECT().GetWorktree("github.com/user/repo1", "develop").Return(nil, status.ErrWorktreeNotFound) + mockGit.EXPECT().GetMainRepositoryPath("/path/to/repo1").Return("/path/to/repo1", nil) + // Branch doesn't exist in managed path, check original path + mockGit.EXPECT().BranchExists("/path/to/repo1", "develop").Return(false, nil) + mockGit.EXPECT().BranchExists("repo1", "develop").Return(false, nil) // Check original path + // Check remote - branch doesn't exist on remote, but worktree creation will create it from default branch + mockGit.EXPECT().BranchExistsOnRemote(gomock.Any()).Return(false, nil) + // Worktree creation will proceed and create the branch from default branch + mockRepo.EXPECT().CreateWorktree("develop", gomock.Any()).Return("/repos/github.com/user/repo1/origin/develop", nil) + mockGit.EXPECT().BranchExists("/path/to/repo1", "develop").Return(true, nil) // Verify after creation + // Workspace file update for develop (second call from createWorktreesForBranches) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[{"path":"/repos/github.com/user/repo1/origin/develop","name":"repo1"}]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // verifyAndCleanupWorktree for develop + mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) + mockGit.EXPECT().GetMainRepositoryPath("/path/to/repo1").Return("/path/to/repo1", nil) + mockGit.EXPECT().BranchExists("/path/to/repo1", "develop").Return(true, nil) + err := cm.AddRepositoryToWorkspace(¶ms) assert.NoError(t, err) } @@ -439,18 +477,16 @@ func TestAddRepositoryToWorkspace_WorktreeCreationFailure(t *testing.T) { // Mock repository exists in status check mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) - // Mock getting existing repository - existingRepoStatus := &status.Repository{ - Path: "/path/to/existing-repo", - Worktrees: map[string]status.WorktreeInfo{ - "origin:main": {Branch: "main", Remote: "origin"}, - }, - } - mockStatus.EXPECT().GetRepository("github.com/user/existing-repo").Return(existingRepoStatus, nil) - // Mock updating workspace in status mockStatus.EXPECT().UpdateWorkspace("test-workspace", gomock.Any()).Return(nil) + // NEW: updateAllWorkspaceFilesForNewRepository is called for ALL branches in workspace.Worktrees + // This happens BEFORE worktree creation + // Branch "main" workspace file update (first call) + mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) + mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) + mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + // Mock GetRepository call at the start of createWorktreesForBranches (for default branch check) mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) @@ -516,38 +552,12 @@ func TestAddRepositoryToWorkspace_WorkspaceFileUpdateFailure(t *testing.T) { // Mock repository exists in status check (using the normalized URL) mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) - // Mock getting existing repository - existingRepoStatus := &status.Repository{ - Path: "/path/to/existing-repo", - Worktrees: map[string]status.WorktreeInfo{ - "origin:main": {Branch: "main", Remote: "origin"}, - }, - } - mockStatus.EXPECT().GetRepository("github.com/user/existing-repo").Return(existingRepoStatus, nil) - // Mock updating workspace in status mockStatus.EXPECT().UpdateWorkspace("test-workspace", gomock.Any()).Return(nil) - // Mock GetRepository call at the start of createWorktreesForBranches (for default branch check) - mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) - - // Mock GetRepository call in createWorktreeForBranchInRepository (to get managed path) - mockStatus.EXPECT().GetRepository("github.com/user/repo1").Return(existingRepo, nil) - - // Mock GetWorktree call to check if worktree already exists (it doesn't, so return error) - mockStatus.EXPECT().GetWorktree("github.com/user/repo1", "main").Return(nil, status.ErrWorktreeNotFound) - - // Mock GetMainRepositoryPath and BranchExists before worktree creation - mockGit.EXPECT().GetMainRepositoryPath("/path/to/repo1").Return("/path/to/repo1", nil) - mockGit.EXPECT().BranchExists("/path/to/repo1", "main").Return(true, nil) - - // Mock worktree creation success - mockRepo.EXPECT().CreateWorktree("main", gomock.Any()).Return("/repos/github.com/user/repo1/origin/main", nil) - - // Mock verifyBranchExistsAfterCreation - mockGit.EXPECT().BranchExists("/path/to/repo1", "main").Return(true, nil) - - // Mock workspace file update failure + // NEW: updateAllWorkspaceFilesForNewRepository is called for ALL branches in workspace.Worktrees + // This happens BEFORE worktree creation + // Branch "main" workspace file update failure (first call) mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil) mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte(`{"folders":[]}`), nil) mockFS.EXPECT().WriteFileAtomic(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("file write failed")) diff --git a/test/workspace_add_test.go b/test/workspace_add_test.go index ce898e5..6d97889 100644 --- a/test/workspace_add_test.go +++ b/test/workspace_add_test.go @@ -258,14 +258,19 @@ func TestAddRepositoryToWorkspaceWithWorktrees(t *testing.T) { assert.DirExists(t, featureWorktreePath, "Feature worktree should exist") // Verify workspace files were updated - workspaceFile1 := filepath.Join(setup.CmPath, "workspaces", "test-workspace", "master.code-workspace") - workspaceFile2 := filepath.Join(setup.CmPath, "workspaces", "test-workspace", "feature-test.code-workspace") // Branch name gets sanitized - - // Check that workspace files exist and contain the new repository - for _, workspaceFile := range []string{workspaceFile1, workspaceFile2} { + workspaceFile1 := filepath.Join(cfg.WorkspacesDir, "test-workspace", "master.code-workspace") + workspaceFile2 := filepath.Join(cfg.WorkspacesDir, "test-workspace", "feature-test.code-workspace") // Branch name gets sanitized + require.NotEmpty(t, repo3URL, "Should find repo3 URL") + + // Check that ALL workspace files exist and contain the new repository + // This is critical - both files should be updated even if worktrees were only created for some branches + for branchName, workspaceFile := range map[string]string{ + "master": workspaceFile1, + "feature/test": workspaceFile2, + } { if _, err := os.Stat(workspaceFile); err == nil { content, err := os.ReadFile(workspaceFile) - require.NoError(t, err) + require.NoError(t, err, "Should be able to read workspace file for %s", branchName) var workspaceConfig struct { Folders []struct { @@ -274,10 +279,23 @@ func TestAddRepositoryToWorkspaceWithWorktrees(t *testing.T) { } `json:"folders"` } err = json.Unmarshal(content, &workspaceConfig) - require.NoError(t, err) + require.NoError(t, err, "Should be able to parse workspace file JSON for %s", branchName) // Should have 3 folders (one for each repository) - require.Len(t, workspaceConfig.Folders, 3, "Workspace file should have 3 folders") + require.Len(t, workspaceConfig.Folders, 3, + "Workspace file for %s should have 3 folders after adding repo3", branchName) + + // Verify repo3 folder entry exists with correct path + expectedRepo3Path := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", branchName) + foundRepo3 := false + for _, folder := range workspaceConfig.Folders { + if folder.Path == expectedRepo3Path { + foundRepo3 = true + break + } + } + require.True(t, foundRepo3, + "Workspace file for %s should contain repo3 folder entry with path %s", branchName, expectedRepo3Path) } } } @@ -411,9 +429,9 @@ func TestAddRepositoryToWorkspaceNoMatchingBranches(t *testing.T) { require.NoError(t, err, "Worktree creation should succeed") // Add second repository to workspace - // Since unique-branch doesn't exist in repo2, no worktrees should be created for it + // Even though unique-branch doesn't exist in repo2, worktree will be created from default branch err = addRepositoryToWorkspace(t, setup, "test-workspace", repo2Path) - require.NoError(t, err, "Adding repository should succeed even if no matching branches") + require.NoError(t, err, "Adding repository should succeed") // Verify the repository was added to workspace status := readStatusFile(t, setup.StatusPath) @@ -421,20 +439,67 @@ func TestAddRepositoryToWorkspaceNoMatchingBranches(t *testing.T) { require.True(t, exists, "Workspace should exist") require.Len(t, workspace.Repositories, 2, "Workspace should have two repositories") - // Verify that repo2 has no worktrees (since unique-branch doesn't exist in it) + // Verify that repo2 has worktree for unique-branch (created from default branch) // Find repo2 in status by checking all repositories var repo2Status Repository + var repo2URL string found := false for url, repo := range status.Repositories { if strings.Contains(url, "Spoon-Knife") || strings.Contains(repo.Path, "Spoon-Knife") { repo2Status = repo + repo2URL = url found = true break } } require.True(t, found, "Repository should be in status") - // Should have no worktrees since unique-branch doesn't exist in repo2 - require.Len(t, repo2Status.Worktrees, 0, "Repository should have no worktrees for non-matching branch") + // Should have worktree for unique-branch (created from default branch even though it didn't exist) + require.Len(t, repo2Status.Worktrees, 1, "Repository should have worktree for unique-branch (created from default branch)") + hasUniqueBranchWorktree := false + for _, worktree := range repo2Status.Worktrees { + if worktree.Branch == "unique-branch" { + hasUniqueBranchWorktree = true + break + } + } + require.True(t, hasUniqueBranchWorktree, "Repo2 should have worktree for unique-branch") + + // CRITICAL: Verify workspace file is still updated even when no worktrees are created + // This is the key test - workspace file should be updated even if the branch doesn't exist in the new repo + cfg, err := config.NewManager(setup.ConfigPath).GetConfigWithFallback() + require.NoError(t, err) + + workspaceFile := filepath.Join(cfg.WorkspacesDir, "test-workspace", "unique-branch.code-workspace") + if _, err := os.Stat(workspaceFile); err == nil { + content, err := os.ReadFile(workspaceFile) + require.NoError(t, err, "Should be able to read workspace file for unique-branch") + + var workspaceConfig struct { + Folders []struct { + Name string `json:"name"` + Path string `json:"path"` + } `json:"folders"` + } + err = json.Unmarshal(content, &workspaceConfig) + require.NoError(t, err, "Should be able to parse workspace file JSON for unique-branch") + + // Should have 2 folders (repo1 and repo2) + require.Len(t, workspaceConfig.Folders, 2, + "Workspace file for unique-branch should have 2 folders after adding repo2") + + // Verify repo2 folder entry exists with correct path (even though worktree doesn't exist) + expectedRepo2Path := filepath.Join(cfg.RepositoriesDir, repo2URL, "origin", "unique-branch") + foundRepo2 := false + for _, folder := range workspaceConfig.Folders { + if folder.Path == expectedRepo2Path { + foundRepo2 = true + break + } + } + require.True(t, foundRepo2, + "Workspace file for unique-branch should contain repo2 folder entry with path %s (even though worktree doesn't exist)", + expectedRepo2Path) + } } // TestAddRepositoryToWorkspaceWithSSHURL tests that workspace file paths use normalized URLs @@ -578,3 +643,603 @@ func TestAddRepositoryToWorkspaceWithSSHURL(t *testing.T) { folder.Path, expectedPath1, expectedPath2) } } + +// TestAddRepositoryToWorkspaceUpdatesAllWorkspaceFiles tests that when adding a repository +// to a workspace with multiple branches, ALL existing workspace files are updated, not just +// the ones where worktrees are created. This reproduces the bug where workspace files were +// only updated for branches where worktrees were created. +func TestAddRepositoryToWorkspaceUpdatesAllWorkspaceFiles(t *testing.T) { + setup := setupTestEnvironment(t) + defer cleanupTestEnvironment(t, setup) + + // Create three test Git repositories with different names to get different remote URLs + // "Hello-World" maps to github.com/octocat/Hello-World + // "Spoon-Knife" maps to github.com/octocat/Spoon-Knife + // "New-Repo" maps to github.com/lerenn/lerenn.github.io + repo1Path := filepath.Join(setup.TempDir, "Hello-World") + repo2Path := filepath.Join(setup.TempDir, "Spoon-Knife") + repo3Path := filepath.Join(setup.TempDir, "New-Repo") + + require.NoError(t, os.MkdirAll(repo1Path, 0755)) + require.NoError(t, os.MkdirAll(repo2Path, 0755)) + require.NoError(t, os.MkdirAll(repo3Path, 0755)) + + createTestGitRepo(t, repo1Path) + createTestGitRepo(t, repo2Path) + createTestGitRepo(t, repo3Path) + + // Set up Git environment variables + gitEnv := append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + + // Switch all repositories to a temporary branch to avoid conflicts when creating worktrees + restore := safeChdir(t, repo1Path) + cmd := exec.Command("git", "checkout", "-b", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo2Path) + cmd = exec.Command("git", "checkout", "-b", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo3Path) + cmd = exec.Command("git", "checkout", "-b", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Create workspace with first two repositories + err := createWorkspace(t, setup, "test-workspace", []string{repo1Path, repo2Path}) + require.NoError(t, err, "Workspace creation should succeed") + + // Create CM instance + cmInstance, err := codemanager.NewCodeManager(codemanager.NewCodeManagerParams{ + Dependencies: createE2EDependencies(setup.ConfigPath). + WithConfig(config.NewManager(setup.ConfigPath)), + }) + require.NoError(t, err) + + // Create branch "extract-lgtm" in both repo1 and repo2 + restore = safeChdir(t, repo1Path) + cmd = exec.Command("git", "checkout", "-b", "extract-lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile := filepath.Join(repo1Path, "lgtm.txt") + require.NoError(t, os.WriteFile(testFile, []byte("lgtm content"), 0644)) + cmd = exec.Command("git", "add", "lgtm.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo2Path) + cmd = exec.Command("git", "checkout", "-b", "extract-lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo2Path, "lgtm.txt") + require.NoError(t, os.WriteFile(testFile, []byte("lgtm content"), 0644)) + cmd = exec.Command("git", "add", "lgtm.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Create branch "extract-homeassistant" in both repo1 and repo2 + restore = safeChdir(t, repo1Path) + cmd = exec.Command("git", "checkout", "-b", "extract-homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo1Path, "homeassistant.txt") + require.NoError(t, os.WriteFile(testFile, []byte("homeassistant content"), 0644)) + cmd = exec.Command("git", "add", "homeassistant.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo2Path) + cmd = exec.Command("git", "checkout", "-b", "extract-homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo2Path, "homeassistant.txt") + require.NoError(t, os.WriteFile(testFile, []byte("homeassistant content"), 0644)) + cmd = exec.Command("git", "add", "homeassistant.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Create worktrees for both branches in the workspace + err = cmInstance.CreateWorkTree("extract-lgtm", codemanager.CreateWorkTreeOpts{ + WorkspaceName: "test-workspace", + }) + require.NoError(t, err, "Worktree creation for extract-lgtm should succeed") + + err = cmInstance.CreateWorkTree("extract-homeassistant", codemanager.CreateWorkTreeOpts{ + WorkspaceName: "test-workspace", + }) + require.NoError(t, err, "Worktree creation for extract-homeassistant should succeed") + + // Verify workspace files exist for both branches + cfg, err := config.NewManager(setup.ConfigPath).GetConfigWithFallback() + require.NoError(t, err) + + workspaceFile1 := filepath.Join(cfg.WorkspacesDir, "test-workspace", "extract-lgtm.code-workspace") + workspaceFile2 := filepath.Join(cfg.WorkspacesDir, "test-workspace", "extract-homeassistant.code-workspace") + + require.FileExists(t, workspaceFile1, "Workspace file for extract-lgtm should exist") + require.FileExists(t, workspaceFile2, "Workspace file for extract-homeassistant should exist") + + // Verify both workspace files have 2 folders (repo1 and repo2) + for _, workspaceFile := range []string{workspaceFile1, workspaceFile2} { + content, err := os.ReadFile(workspaceFile) + require.NoError(t, err) + + var workspaceConfig struct { + Folders []struct { + Name string `json:"name"` + Path string `json:"path"` + } `json:"folders"` + } + err = json.Unmarshal(content, &workspaceConfig) + require.NoError(t, err) + + require.Len(t, workspaceConfig.Folders, 2, "Workspace file should have 2 folders before adding repo3") + } + + // Prepare repo3: create ONLY extract-homeassistant branch (NOT extract-lgtm) + // This ensures worktrees are only created for extract-homeassistant, not extract-lgtm + restore = safeChdir(t, repo3Path) + cmd = exec.Command("git", "checkout", "-b", "extract-homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo3Path, "homeassistant.txt") + require.NoError(t, os.WriteFile(testFile, []byte("homeassistant content"), 0644)) + cmd = exec.Command("git", "add", "homeassistant.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Add third repository to workspace + err = addRepositoryToWorkspace(t, setup, "test-workspace", repo3Path) + require.NoError(t, err, "Adding repository to workspace should succeed") + + // Verify status.yaml was updated + status := readStatusFile(t, setup.StatusPath) + workspace, exists := status.Workspaces["test-workspace"] + require.True(t, exists, "Workspace should exist in status file") + require.Len(t, workspace.Repositories, 3, "Workspace should have three repositories after adding") + + // Find repo3 URL from status + var repo3URL string + for url := range status.Repositories { + if strings.Contains(url, "repo3") || strings.Contains(url, "lerenn.github.io") { + repo3URL = url + break + } + } + require.NotEmpty(t, repo3URL, "Should find repo3 URL") + + // Verify worktrees were created for BOTH branches (extract-lgtm and extract-homeassistant) + // Even though extract-lgtm doesn't exist in repo3, it will be created from default branch + repo3Status, exists := status.Repositories[repo3URL] + require.True(t, exists, "Repo3 should exist in status") + // Should have worktrees for both branches (they'll be created from default branch if they don't exist) + require.Len(t, repo3Status.Worktrees, 2, "Repo3 should have worktrees for both branches") + hasLgtmWorktree := false + hasHomeassistantWorktree := false + for _, worktree := range repo3Status.Worktrees { + if worktree.Branch == "extract-lgtm" { + hasLgtmWorktree = true + } + if worktree.Branch == "extract-homeassistant" { + hasHomeassistantWorktree = true + } + } + require.True(t, hasLgtmWorktree, "Repo3 should have worktree for extract-lgtm") + require.True(t, hasHomeassistantWorktree, "Repo3 should have worktree for extract-homeassistant") + + // CRITICAL: Verify BOTH workspace files are updated with repo3 + // This is the main test - both files should be updated even though worktree was only created for one branch + for branchName, workspaceFile := range map[string]string{ + "extract-lgtm": workspaceFile1, + "extract-homeassistant": workspaceFile2, + } { + content, err := os.ReadFile(workspaceFile) + require.NoError(t, err, "Should be able to read workspace file for %s", branchName) + + var workspaceConfig struct { + Folders []struct { + Name string `json:"name"` + Path string `json:"path"` + } `json:"folders"` + } + err = json.Unmarshal(content, &workspaceConfig) + require.NoError(t, err, "Should be able to parse workspace file JSON for %s", branchName) + + // Should have 3 folders (repo1, repo2, and repo3) + require.Len(t, workspaceConfig.Folders, 3, + "Workspace file for %s should have 3 folders after adding repo3", branchName) + + // Verify repo3 folder entry exists with correct path + foundRepo3 := false + expectedRepo3Path := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", branchName) + for _, folder := range workspaceConfig.Folders { + if folder.Path == expectedRepo3Path { + foundRepo3 = true + // Verify repository name extraction + expectedRepoName := extractRepositoryNameFromURL(repo3URL) + assert.Equal(t, expectedRepoName, folder.Name, + "Repository name should be correctly extracted for %s", branchName) + break + } + } + require.True(t, foundRepo3, + "Workspace file for %s should contain repo3 folder entry with path %s", branchName, expectedRepo3Path) + } + + // Verify worktree exists for extract-homeassistant + homeassistantWorktreePath := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", "extract-homeassistant") + assert.DirExists(t, homeassistantWorktreePath, "Homeassistant worktree should exist") + + // Verify worktree DOES exist for extract-lgtm (created from default branch even though branch didn't exist) + lgtmWorktreePath := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", "extract-lgtm") + assert.DirExists(t, lgtmWorktreePath, "LGTM worktree should exist (created from default branch)") +} + +// TestAddRepositoryToWorkspaceWithNoExistingWorktrees tests adding a repository +// to a workspace that has no existing worktrees (no workspace files exist yet). +// This should handle gracefully without errors. +func TestAddRepositoryToWorkspaceWithNoExistingWorktrees(t *testing.T) { + setup := setupTestEnvironment(t) + defer cleanupTestEnvironment(t, setup) + + // Create two test Git repositories with different names to get different remote URLs + // "Hello-World" maps to github.com/octocat/Hello-World + // "Spoon-Knife" maps to github.com/octocat/Spoon-Knife + repo1Path := filepath.Join(setup.TempDir, "Hello-World") + repo2Path := filepath.Join(setup.TempDir, "Spoon-Knife") + + require.NoError(t, os.MkdirAll(repo1Path, 0755)) + require.NoError(t, os.MkdirAll(repo2Path, 0755)) + + createTestGitRepo(t, repo1Path) + createTestGitRepo(t, repo2Path) + + // Create workspace with first repository (no worktrees created) + err := createWorkspace(t, setup, "test-workspace", []string{repo1Path}) + require.NoError(t, err, "Workspace creation should succeed") + + // Verify workspace has no worktrees in status + status := readStatusFile(t, setup.StatusPath) + workspace, exists := status.Workspaces["test-workspace"] + require.True(t, exists, "Workspace should exist") + require.Len(t, workspace.Worktrees, 0, "Workspace should have no worktrees initially") + + // Verify no workspace files exist + cfg, err := config.NewManager(setup.ConfigPath).GetConfigWithFallback() + require.NoError(t, err) + workspacesDir := cfg.WorkspacesDir + workspaceDir := filepath.Join(workspacesDir, "test-workspace") + _, err = os.Stat(workspaceDir) + require.Error(t, err, "Workspace directory should not exist yet") + + // Add second repository to workspace + // This should succeed gracefully even though no workspace files exist + err = addRepositoryToWorkspace(t, setup, "test-workspace", repo2Path) + require.NoError(t, err, "Adding repository should succeed even with no existing worktrees") + + // Verify status.yaml was updated + status = readStatusFile(t, setup.StatusPath) + workspace, exists = status.Workspaces["test-workspace"] + require.True(t, exists, "Workspace should exist") + require.Len(t, workspace.Repositories, 2, "Workspace should have two repositories") +} + +// TestAddRepositoryToWorkspaceCreatesWorktreesForAllBranches tests that when adding +// a repository to a workspace, worktrees are created for ALL branches in the workspace, +// not just branches that exist in all existing repositories. +func TestAddRepositoryToWorkspaceCreatesWorktreesForAllBranches(t *testing.T) { + setup := setupTestEnvironment(t) + defer cleanupTestEnvironment(t, setup) + + // Create three test Git repositories with different names to get different remote URLs + // "Hello-World" maps to github.com/octocat/Hello-World + // "Spoon-Knife" maps to github.com/octocat/Spoon-Knife + // "New-Repo" maps to github.com/lerenn/lerenn.github.io + repo1Path := filepath.Join(setup.TempDir, "Hello-World") + repo2Path := filepath.Join(setup.TempDir, "Spoon-Knife") + repo3Path := filepath.Join(setup.TempDir, "New-Repo") + + require.NoError(t, os.MkdirAll(repo1Path, 0755)) + require.NoError(t, os.MkdirAll(repo2Path, 0755)) + require.NoError(t, os.MkdirAll(repo3Path, 0755)) + + createTestGitRepo(t, repo1Path) + createTestGitRepo(t, repo2Path) + createTestGitRepo(t, repo3Path) + + // Set up Git environment variables + gitEnv := append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + + // Switch all repositories to a temporary branch to avoid conflicts when creating worktrees + restore := safeChdir(t, repo1Path) + cmd := exec.Command("git", "checkout", "-b", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo2Path) + cmd = exec.Command("git", "checkout", "-b", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo3Path) + cmd = exec.Command("git", "checkout", "-b", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Create workspace with first two repositories + err := createWorkspace(t, setup, "test-workspace", []string{repo1Path, repo2Path}) + require.NoError(t, err, "Workspace creation should succeed") + + // Create CM instance + cmInstance, err := codemanager.NewCodeManager(codemanager.NewCodeManagerParams{ + Dependencies: createE2EDependencies(setup.ConfigPath). + WithConfig(config.NewManager(setup.ConfigPath)), + }) + require.NoError(t, err) + + // Create branch "extract-lgtm" in both repo1 and repo2 + restore = safeChdir(t, repo1Path) + cmd = exec.Command("git", "checkout", "-b", "extract-lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile := filepath.Join(repo1Path, "lgtm.txt") + require.NoError(t, os.WriteFile(testFile, []byte("lgtm content"), 0644)) + cmd = exec.Command("git", "add", "lgtm.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo2Path) + cmd = exec.Command("git", "checkout", "-b", "extract-lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo2Path, "lgtm.txt") + require.NoError(t, os.WriteFile(testFile, []byte("lgtm content"), 0644)) + cmd = exec.Command("git", "add", "lgtm.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Create branch "extract-homeassistant" in both repo1 and repo2 + restore = safeChdir(t, repo1Path) + cmd = exec.Command("git", "checkout", "-b", "extract-homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo1Path, "homeassistant.txt") + require.NoError(t, os.WriteFile(testFile, []byte("homeassistant content"), 0644)) + cmd = exec.Command("git", "add", "homeassistant.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + restore = safeChdir(t, repo2Path) + cmd = exec.Command("git", "checkout", "-b", "extract-homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo2Path, "homeassistant.txt") + require.NoError(t, os.WriteFile(testFile, []byte("homeassistant content"), 0644)) + cmd = exec.Command("git", "add", "homeassistant.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Create worktrees for both branches in the workspace + err = cmInstance.CreateWorkTree("extract-lgtm", codemanager.CreateWorkTreeOpts{ + WorkspaceName: "test-workspace", + }) + require.NoError(t, err, "Worktree creation for extract-lgtm should succeed") + + err = cmInstance.CreateWorkTree("extract-homeassistant", codemanager.CreateWorkTreeOpts{ + WorkspaceName: "test-workspace", + }) + require.NoError(t, err, "Worktree creation for extract-homeassistant should succeed") + + // Verify workspace has both branches + cfg, err := config.NewManager(setup.ConfigPath).GetConfigWithFallback() + require.NoError(t, err) + + status := readStatusFile(t, setup.StatusPath) + workspace, exists := status.Workspaces["test-workspace"] + require.True(t, exists, "Workspace should exist in status file") + require.Len(t, workspace.Worktrees, 2, "Workspace should have 2 branches") + require.Contains(t, workspace.Worktrees, "extract-lgtm", "Workspace should have extract-lgtm branch") + require.Contains(t, workspace.Worktrees, "extract-homeassistant", "Workspace should have extract-homeassistant branch") + + // CRITICAL: Prepare repo3 with BOTH branches (extract-lgtm AND extract-homeassistant) + // This ensures worktrees are created for BOTH branches when adding repo3 + restore = safeChdir(t, repo3Path) + // Create extract-lgtm branch + cmd = exec.Command("git", "checkout", "-b", "extract-lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo3Path, "lgtm.txt") + require.NoError(t, os.WriteFile(testFile, []byte("lgtm content"), 0644)) + cmd = exec.Command("git", "add", "lgtm.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add lgtm") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + // Create extract-homeassistant branch + cmd = exec.Command("git", "checkout", "-b", "extract-homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + testFile = filepath.Join(repo3Path, "homeassistant.txt") + require.NoError(t, os.WriteFile(testFile, []byte("homeassistant content"), 0644)) + cmd = exec.Command("git", "add", "homeassistant.txt") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "commit", "-m", "Add homeassistant") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "checkout", "temp-branch") + cmd.Env = gitEnv + require.NoError(t, cmd.Run()) + restore() + + // Add third repository to workspace + err = addRepositoryToWorkspace(t, setup, "test-workspace", repo3Path) + require.NoError(t, err, "Adding repository to workspace should succeed") + + // Verify status.yaml was updated + status = readStatusFile(t, setup.StatusPath) + workspace, exists = status.Workspaces["test-workspace"] + require.True(t, exists, "Workspace should exist in status file") + require.Len(t, workspace.Repositories, 3, "Workspace should have three repositories after adding") + + // Find repo3 URL from status + var repo3URL string + for url := range status.Repositories { + if strings.Contains(url, "lerenn.github.io") { + repo3URL = url + break + } + } + require.NotEmpty(t, repo3URL, "Should find repo3 URL") + + // CRITICAL: Verify worktrees were created for BOTH branches (extract-lgtm AND extract-homeassistant) + repo3Status, exists := status.Repositories[repo3URL] + require.True(t, exists, "Repo3 should exist in status") + require.Len(t, repo3Status.Worktrees, 2, "Repo3 should have worktrees for BOTH branches") + hasLgtmWorktree := false + hasHomeassistantWorktree := false + for _, worktree := range repo3Status.Worktrees { + if worktree.Branch == "extract-lgtm" { + hasLgtmWorktree = true + } + if worktree.Branch == "extract-homeassistant" { + hasHomeassistantWorktree = true + } + } + require.True(t, hasLgtmWorktree, "Repo3 should have worktree for extract-lgtm") + require.True(t, hasHomeassistantWorktree, "Repo3 should have worktree for extract-homeassistant") + + // Verify worktree directories exist + expectedLgtmPath := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", "extract-lgtm") + expectedHomeassistantPath := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", "extract-homeassistant") + require.DirExists(t, expectedLgtmPath, "Worktree directory for extract-lgtm should exist") + require.DirExists(t, expectedHomeassistantPath, "Worktree directory for extract-homeassistant should exist") + + // Verify BOTH workspace files are updated with repo3 + workspaceFile1 := filepath.Join(cfg.WorkspacesDir, "test-workspace", "extract-lgtm.code-workspace") + workspaceFile2 := filepath.Join(cfg.WorkspacesDir, "test-workspace", "extract-homeassistant.code-workspace") + + for branchName, workspaceFile := range map[string]string{ + "extract-lgtm": workspaceFile1, + "extract-homeassistant": workspaceFile2, + } { + content, err := os.ReadFile(workspaceFile) + require.NoError(t, err, "Should be able to read workspace file for %s", branchName) + + var workspaceConfig struct { + Folders []struct { + Name string `json:"name"` + Path string `json:"path"` + } `json:"folders"` + } + err = json.Unmarshal(content, &workspaceConfig) + require.NoError(t, err, "Should be able to parse workspace file JSON for %s", branchName) + + // Should have 3 folders (repo1, repo2, and repo3) + require.Len(t, workspaceConfig.Folders, 3, + "Workspace file for %s should have 3 folders after adding repo3", branchName) + + // Verify repo3 folder entry exists with correct path + foundRepo3 := false + expectedRepo3Path := filepath.Join(cfg.RepositoriesDir, repo3URL, "origin", branchName) + for _, folder := range workspaceConfig.Folders { + if folder.Path == expectedRepo3Path { + foundRepo3 = true + break + } + } + require.True(t, foundRepo3, "Workspace file for %s should contain repo3 folder entry", branchName) + } +} + +// extractRepositoryNameFromURL extracts the repository name from a URL (helper for test) +func extractRepositoryNameFromURL(repoURL string) string { + repoURL = strings.TrimSuffix(repoURL, "/") + parts := strings.Split(repoURL, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return repoURL +}