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 +}