diff --git a/cmd/cm/repository/delete.go b/cmd/cm/repository/delete.go index aad0c94..ce42610 100644 --- a/cmd/cm/repository/delete.go +++ b/cmd/cm/repository/delete.go @@ -12,7 +12,7 @@ func createDeleteCmd() *cobra.Command { var force bool deleteCmd := &cobra.Command{ - Use: "delete ", + Use: "delete [repository-name]", Short: "Delete a repository and all associated resources", Long: `Delete a repository from CM and remove all associated worktrees and files. @@ -21,13 +21,16 @@ This command will: • Remove the repository from the status file • Delete the repository directory (if within base path) +If no repository name is provided, you will be prompted to select one interactively. + Use the --force flag to skip confirmation prompts. Examples: cm repository delete my-repo cm repo delete https://github.com/user/repo.git - cm r delete my-repo --force`, - Args: cobra.ExactArgs(1), + cm r delete my-repo --force + cm r delete # Interactive selection`, + Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { if err := cli.CheckInitialization(); err != nil { return err @@ -41,10 +44,12 @@ Examples: cmManager.SetLogger(logger.NewVerboseLogger()) } - // Create delete parameters + // Create delete parameters (interactive selection handled in code-manager) params := cm.DeleteRepositoryParams{ - RepositoryName: args[0], - Force: force, + Force: force, + } + if len(args) > 0 { + params.RepositoryName = args[0] } return cmManager.DeleteRepository(params) diff --git a/cmd/cm/repository/list.go b/cmd/cm/repository/list.go index 8fccd21..e62f826 100644 --- a/cmd/cm/repository/list.go +++ b/cmd/cm/repository/list.go @@ -16,8 +16,7 @@ func createListCmd() *cobra.Command { Short: "List all repositories in CM", Long: `List all repositories tracked by CM with visual indicators. -Repositories are displayed in a numbered list format. An asterisk (*) indicates -repositories that are not within the configured base path. +An asterisk (*) indicates repositories that are not within the configured base path. Examples: cm repository list @@ -48,12 +47,12 @@ Examples: return nil } - for i, repo := range repositories { + for _, repo := range repositories { indicator := "" if !repo.InRepositoriesDir { indicator = "*" } - fmt.Printf(" %d. %s%s\n", i+1, indicator, repo.Name) + fmt.Printf(" %s%s\n", indicator, repo.Name) } return nil diff --git a/cmd/cm/workspace/delete.go b/cmd/cm/workspace/delete.go index 4fd3e6c..483a9d9 100644 --- a/cmd/cm/workspace/delete.go +++ b/cmd/cm/workspace/delete.go @@ -12,10 +12,10 @@ import ( func createDeleteCmd() *cobra.Command { deleteCmd := &cobra.Command{ - Use: "delete ", + Use: "delete [workspace-name]", Short: "Delete a workspace and all associated resources", Long: getDeleteCommandLongDescription(), - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: createDeleteCmdRunE, } @@ -40,6 +40,8 @@ The deletion process includes: - Removing the workspace entry from status.yaml - Preserving individual repository entries (they may be used by other workspaces) +If no workspace name is provided, you will be prompted to select one interactively. + By default, the command will show a confirmation prompt with a detailed summary of what will be deleted. Use the --force flag to skip confirmation prompts. @@ -48,13 +50,14 @@ Examples: cm workspace delete my-workspace # Delete workspace without confirmation - cm workspace delete my-workspace --force` + cm workspace delete my-workspace --force + + # Interactive selection + cm ws delete` } // createDeleteCmdRunE creates the RunE function for the delete command. func createDeleteCmdRunE(cmd *cobra.Command, args []string) error { - workspaceName := args[0] - // Get force flag force, err := cmd.Flags().GetBool("force") if err != nil { @@ -72,19 +75,23 @@ func createDeleteCmdRunE(cmd *cobra.Command, args []string) error { cmManager.SetLogger(logger.NewVerboseLogger()) } - // Delete workspace + // Create delete parameters (interactive selection handled in code-manager) params := cm.DeleteWorkspaceParams{ - WorkspaceName: workspaceName, + WorkspaceName: "", Force: force, } + if len(args) > 0 { + params.WorkspaceName = args[0] + } + // Delete workspace (interactive selection handled in code-manager) if err := cmManager.DeleteWorkspace(params); err != nil { return err } // Print success message if !cli.Quiet { - fmt.Printf("✓ Workspace '%s' deleted successfully\n", workspaceName) + fmt.Printf("✓ Workspace '%s' deleted successfully\n", params.WorkspaceName) } return nil diff --git a/cmd/cm/worktree/create.go b/cmd/cm/worktree/create.go index e078e5c..ae02b58 100644 --- a/cmd/cm/worktree/create.go +++ b/cmd/cm/worktree/create.go @@ -39,9 +39,9 @@ func createCreateCmd() *cobra.Command { createCmd.Flags().StringVar(&fromIssue, "from-issue", "", "Create worktree from GitHub issue (URL, number, or owner/repo#issue format)") createCmd.Flags().StringVarP(&workspaceName, "workspace", "w", "", - "Create worktrees from workspace definition in status.yaml") + "Create worktrees from workspace definition in status.yaml (interactive selection if not provided)") createCmd.Flags().StringVarP(&repositoryName, "repository", "r", "", - "Create worktree for the specified repository (name from status.yaml or path)") + "Create worktree for the specified repository (name from status.yaml or path, interactive selection if not provided)") return createCmd } @@ -59,7 +59,7 @@ Issue Reference Formats: - Owner/repo#issue format: owner/repo#123 Examples: - cm worktree create feature-branch + cm worktree create feature-branch # Interactive selection of workspace/repository cm wt create feature-branch --ide ` + ide.DefaultIDE + ` cm w create feature-branch --ide cursor cm worktree create --from-issue https://github.com/owner/repo/issues/123 @@ -93,8 +93,8 @@ func createCreateCmdArgsValidator( if *workspaceName != "" || *repositoryName != "" { return cobra.ExactArgs(1)(cmd, args) } - // Otherwise, branch name is required - return cobra.ExactArgs(1)(cmd, args) + // Otherwise, branch name is optional (interactive selection will handle target selection) + return cobra.MaximumNArgs(1)(cmd, args) } } diff --git a/cmd/cm/worktree/delete.go b/cmd/cm/worktree/delete.go index 2c40e7c..5285e7a 100644 --- a/cmd/cm/worktree/delete.go +++ b/cmd/cm/worktree/delete.go @@ -16,11 +16,12 @@ func createDeleteCmd() *cobra.Command { var all bool deleteCmd := &cobra.Command{ - Use: "delete [branch2] [branch3] ... [--force/-f] [--workspace/-w] [--repository/-r] [--all/-a]", - Short: "Delete worktrees for the specified branches or all worktrees", - Long: getDeleteCmdLongDescription(), - Args: createDeleteCmdArgsValidator(&all, &workspaceName, &repositoryName), - RunE: createDeleteCmdRunE(&all, &force, &workspaceName, &repositoryName), + Use: "delete [branch] [branch2] [branch3] ... [--force/-f] [--workspace/-w] [--repository/-r] [--all/-a]", + Short: "Delete worktrees for the specified branches or all worktrees " + + "(two-step interactive selection if no branch provided)", + Long: getDeleteCmdLongDescription(), + Args: createDeleteCmdArgsValidator(&all, &workspaceName, &repositoryName), + RunE: createDeleteCmdRunE(&all, &force, &workspaceName, &repositoryName), } addDeleteCmdFlags(deleteCmd, &force, &workspaceName, &repositoryName, &all) @@ -31,10 +32,13 @@ func getDeleteCmdLongDescription() string { return `Delete worktrees for the specified branches or all worktrees. You can delete multiple worktrees at once by providing multiple branch names, -or delete all worktrees using the --all flag. +or delete all worktrees using the --all flag. If no branch is specified, +interactive selection will prompt you to choose a repository/workspace first, +then select a specific worktree from that target. Examples: - cm worktree delete feature-branch + cm worktree delete # Two-step: select repository/workspace, then worktree + cm worktree delete feature-branch # One-step: select repository/workspace only cm wt delete feature-branch --force cm w delete feature-branch --force cm wt delete feature-branch --workspace my-workspace @@ -61,8 +65,10 @@ func createDeleteCmdArgsValidator( if *all && len(args) > 0 { return fmt.Errorf("cannot specify both --all flag and branch names") } + // Allow no arguments for interactive selection if !*all && len(args) == 0 { - return fmt.Errorf("must specify either branch names or --all flag") + // This will trigger interactive selection in the code-manager + return nil } return nil } @@ -90,6 +96,7 @@ func createDeleteCmdRunE( if *all { return cmManager.DeleteAllWorktrees(*force) } + return runDeleteWorktree(args, *force, *workspaceName, *repositoryName) } } @@ -97,9 +104,9 @@ func createDeleteCmdRunE( func addDeleteCmdFlags(cmd *cobra.Command, force *bool, workspaceName *string, repositoryName *string, all *bool) { cmd.Flags().BoolVarP(force, "force", "f", false, "Skip confirmation prompts") cmd.Flags().StringVarP(workspaceName, "workspace", "w", "", - "Name of the workspace to delete worktree from (optional)") + "Name of the workspace to delete worktree from (interactive selection if not provided)") cmd.Flags().StringVarP(repositoryName, "repository", "r", "", - "Name of the repository to delete worktree from (optional)") + "Name of the repository to delete worktree from (interactive selection if not provided)") cmd.Flags().BoolVarP(all, "all", "a", false, "Delete all worktrees") } @@ -116,6 +123,11 @@ func runDeleteWorktree(args []string, force bool, workspaceName string, reposito return deleteWorktreesIndividually(cmManager, args, force, opts) } + // If no arguments provided, use single worktree deletion with interactive selection + if len(args) == 0 { + return cmManager.DeleteWorkTree("", force, opts...) + } + // Otherwise use bulk deletion return cmManager.DeleteWorkTrees(args, force) } diff --git a/cmd/cm/worktree/list.go b/cmd/cm/worktree/list.go index 5df34f5..f18207c 100644 --- a/cmd/cm/worktree/list.go +++ b/cmd/cm/worktree/list.go @@ -26,9 +26,9 @@ func createListCmd() *cobra.Command { // Add workspace and repository flags to list command (optional) listCmd.Flags().StringVarP(&workspaceName, "workspace", "w", "", - "Name of the workspace to list worktrees for (optional)") + "Name of the workspace to list worktrees for (interactive selection if not provided)") listCmd.Flags().StringVarP(&repositoryName, "repository", "r", "", - "Name of the repository to list worktrees for (optional)") + "Name of the repository to list worktrees for (interactive selection if not provided)") return listCmd } @@ -37,7 +37,7 @@ func getListCmdLongDescription() string { return `List all worktrees for a specific workspace, repository, or current repository. Examples: - cm worktree list # List worktrees for current repository + cm worktree list # Interactive selection of workspace/repository cm worktree list --workspace my-workspace # List worktrees for specific workspace cm worktree list --repository my-repo # List worktrees for specific repository cm wt list -w my-workspace @@ -59,6 +59,7 @@ func createListCmdRunE(workspaceName, repositoryName *string) func(*cobra.Comman opts := buildListWorktreesOptions(*workspaceName, *repositoryName) + // List worktrees (interactive selection handled in code-manager) worktrees, err := cmManager.ListWorktrees(opts...) if err != nil { return fmt.Errorf("failed to list worktrees: %w", err) diff --git a/cmd/cm/worktree/load.go b/cmd/cm/worktree/load.go index 6a08766..35a6d86 100644 --- a/cmd/cm/worktree/load.go +++ b/cmd/cm/worktree/load.go @@ -20,13 +20,13 @@ func createLoadCmd() *cobra.Command { The remote part is optional and defaults to "origin" if not specified. Examples: - cm worktree load feature-branch # Uses origin:feature-branch + cm worktree load feature-branch # Interactive repository selection, uses origin:feature-branch cm wt load origin:feature-branch # Explicitly specify remote cm w load upstream:main # Use different remote cm worktree load feature-branch --ide ` + ide.DefaultIDE + ` cm worktree load feature-branch --repository my-repo cm wt load origin:main --repository /path/to/repo --ide cursor`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { if err := cli.CheckInitialization(); err != nil { return err @@ -40,7 +40,7 @@ Examples: cmManager.SetLogger(logger.NewVerboseLogger()) } - // Prepare options for LoadWorktree + // Prepare options for LoadWorktree (interactive selection handled in code-manager) var opts cm.LoadWorktreeOpts if ideName != "" { opts.IDEName = ideName @@ -49,15 +49,19 @@ Examples: opts.RepositoryName = repositoryName } - // Load the worktree (parsing is handled by CM manager) - return cmManager.LoadWorktree(args[0], opts) + // Load the worktree (interactive selection handled in code-manager, parsing is handled by CM manager) + branchRef := "" + if len(args) > 0 { + branchRef = args[0] + } + return cmManager.LoadWorktree(branchRef, opts) }, } // Add IDE and repository flags to load command loadCmd.Flags().StringVarP(&ideName, "ide", "i", ide.DefaultIDE, "Open in specified IDE after loading") loadCmd.Flags().StringVarP(&repositoryName, "repository", "r", "", - "Load worktree for the specified repository (name from status.yaml or path)") + "Load worktree for the specified repository (name from status.yaml or path, interactive selection if not provided)") return loadCmd } diff --git a/cmd/cm/worktree/open.go b/cmd/cm/worktree/open.go index 8492856..51e5ac6 100644 --- a/cmd/cm/worktree/open.go +++ b/cmd/cm/worktree/open.go @@ -17,30 +17,34 @@ func createOpenCmd() *cobra.Command { var workspaceName string openCmd := &cobra.Command{ - Use: "open [--ide ] [--workspace ] [--repository ]", + Use: "open [branch] [--ide ] [--workspace ] [--repository ]", Short: "Open a worktree in the specified IDE", Long: `Open a worktree for the specified branch in the specified IDE. Examples: - cm worktree open feature-branch + cm worktree open feature-branch # Interactive selection of workspace/repository cm wt open main cm w open feature-branch -i cursor cm worktree open main --ide ` + ide.DefaultIDE + ` cm worktree open feature-branch --workspace my-workspace cm worktree open feature-branch --repository my-repo cm wt open main --repository /path/to/repo --ide cursor`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return openWorktree(args[0], ideName, workspaceName, repositoryName) + branchName := "" + if len(args) > 0 { + branchName = args[0] + } + return openWorktree(branchName, ideName, workspaceName, repositoryName) }, } // Add IDE, workspace, and repository flags to open command openCmd.Flags().StringVarP(&ideName, "ide", "i", "", "Open in specified IDE") openCmd.Flags().StringVarP(&workspaceName, "workspace", "w", "", - "Open worktree for the specified workspace (name from status.yaml)") + "Open worktree for the specified workspace (name from status.yaml, interactive selection if not provided)") openCmd.Flags().StringVarP(&repositoryName, "repository", "r", "", - "Open worktree for the specified repository (name from status.yaml or path)") + "Open worktree for the specified repository (name from status.yaml or path, interactive selection if not provided)") return openCmd } @@ -65,7 +69,7 @@ func openWorktree(branchName, ideName, workspaceName, repositoryName string) err ideToUse = ideName } - // Prepare options for OpenWorktree + // Prepare options for OpenWorktree (interactive selection handled in code-manager) var opts []cm.OpenWorktreeOpts if workspaceName != "" { opts = append(opts, cm.OpenWorktreeOpts{ @@ -78,7 +82,7 @@ func openWorktree(branchName, ideName, workspaceName, repositoryName string) err }) } - // Open the worktree + // Open the worktree (interactive selection handled in code-manager) if err := cmManager.OpenWorktree(branchName, ideToUse, opts...); err != nil { return fmt.Errorf("failed to open worktree: %w", err) } diff --git a/go.mod b/go.mod index edddcfa..7c4cfa9 100644 --- a/go.mod +++ b/go.mod @@ -11,13 +11,33 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect golang.org/x/tools v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index 1781476..9063d44 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,22 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -10,21 +26,48 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/code-manager/code_manager.go b/pkg/code-manager/code_manager.go index 61aeb94..49eebdd 100644 --- a/pkg/code-manager/code_manager.go +++ b/pkg/code-manager/code_manager.go @@ -244,9 +244,27 @@ func (c *realCodeManager) detectProjectMode(workspaceName, repositoryName string return mode.ModeWorkspace, nil } - // If repositoryName is provided, return single repository mode + // If repositoryName is provided, validate it exists and return single repository mode if repositoryName != "" { c.VerbosePrint("Repository mode detected (repository: %s)", repositoryName) + + // Create repository instance to validate the repository exists + repoProvider := c.deps.RepositoryProvider + repoInstance := repoProvider(repository.NewRepositoryParams{ + Dependencies: c.deps, + RepositoryName: repositoryName, + }) + + // Check if the repository exists + exists, err := repoInstance.IsGitRepository() + if err != nil { + return mode.ModeNone, fmt.Errorf("failed to check repository %s: %w", repositoryName, err) + } + if !exists { + c.VerbosePrint("Repository %s does not exist or is not a Git repository", repositoryName) + return mode.ModeNone, nil + } + return mode.ModeSingleRepo, nil } diff --git a/pkg/code-manager/consts/operations.go b/pkg/code-manager/consts/operations.go index 5f41205..7ed2644 100644 --- a/pkg/code-manager/consts/operations.go +++ b/pkg/code-manager/consts/operations.go @@ -20,6 +20,9 @@ const ( // Workspace operations. ListWorkspaces = "ListWorkspaces" + // Prompt operations. + PromptSelectTarget = "PromptSelectTarget" + // Initialization operations. Init = "Init" ) diff --git a/pkg/code-manager/prompt_select.go b/pkg/code-manager/prompt_select.go new file mode 100644 index 0000000..542e7b6 --- /dev/null +++ b/pkg/code-manager/prompt_select.go @@ -0,0 +1,354 @@ +package codemanager + +import ( + "fmt" + "sort" + + "github.com/lerenn/code-manager/pkg/code-manager/consts" + "github.com/lerenn/code-manager/pkg/prompt" +) + +// TargetSelectionResult represents the result of an interactive target selection. +type TargetSelectionResult struct { + Name string // The name of the selected repository or workspace + Type string // The type of the selected target (repository or workspace) + Worktree string // The selected worktree name (empty for single-step selection) +} + +// promptSelectTargetAndWorktree prompts the user to select a repository/workspace first, then a worktree. +func (c *realCodeManager) promptSelectTargetAndWorktree() (TargetSelectionResult, error) { + // Step 1: Select target + targetResult, err := c.promptSelectTarget("", "two-step selection") + if err != nil { + return TargetSelectionResult{}, err + } + + // Step 2: Select worktree from the chosen target + worktreeChoices, err := c.buildWorktreeChoices(targetResult.Type, targetResult.Name) + if err != nil { + return TargetSelectionResult{}, fmt.Errorf("failed to build worktree choices: %w", err) + } + + if len(worktreeChoices) == 0 { + return TargetSelectionResult{}, fmt.Errorf("no worktrees available for selected %s: %s", + targetResult.Type, targetResult.Name) + } + + if c.deps.Logger != nil { + c.deps.Logger.Logf("Step 2: Prompting user to select worktree from %d choices", len(worktreeChoices)) + } + + // Use the prompt package to get worktree selection + selectedWorktreeChoice, err := c.deps.Prompt.PromptSelectTarget(worktreeChoices, false) + if err != nil { + return TargetSelectionResult{}, fmt.Errorf("failed to get worktree selection: %w", err) + } + + if c.deps.Logger != nil { + c.deps.Logger.Logf("User selected worktree: %s", selectedWorktreeChoice.Name) + } + + return TargetSelectionResult{ + Name: targetResult.Name, + Type: targetResult.Type, + Worktree: selectedWorktreeChoice.Name, + }, nil +} + +// buildWorktreeChoices builds a list of worktree choices for a specific target. +func (c *realCodeManager) buildWorktreeChoices(targetType, targetName string) ([]prompt.TargetChoice, error) { + var choices []prompt.TargetChoice + + switch targetType { + case prompt.TargetRepository: + // Get worktrees for the repository + worktrees, err := c.ListWorktrees(ListWorktreesOpts{ + RepositoryName: targetName, + }) + if err != nil { + return nil, fmt.Errorf("failed to list worktrees for repository %s: %w", targetName, err) + } + + for _, worktree := range worktrees { + choices = append(choices, prompt.TargetChoice{ + Type: prompt.TargetRepository, // Keep same type for consistency + Name: worktree.Branch, + }) + } + case prompt.TargetWorkspace: + // Get worktrees for the workspace + worktrees, err := c.ListWorktrees(ListWorktreesOpts{ + WorkspaceName: targetName, + }) + if err != nil { + return nil, fmt.Errorf("failed to list worktrees for workspace %s: %w", targetName, err) + } + + // Deduplicate worktrees by branch name to avoid duplicates + seenBranches := make(map[string]bool) + for _, worktree := range worktrees { + if !seenBranches[worktree.Branch] { + seenBranches[worktree.Branch] = true + choices = append(choices, prompt.TargetChoice{ + Type: prompt.TargetWorkspace, // Keep same type for consistency + Name: worktree.Branch, + }) + } + } + default: + return nil, fmt.Errorf("unknown target type: %s", targetType) + } + + // Sort choices by name + sort.Slice(choices, func(i, j int) bool { + return choices[i].Name < choices[j].Name + }) + + return choices, nil +} + +// promptSelectTargetOnly prompts the user to select a repository or workspace only (no worktree selection). +func (c *realCodeManager) promptSelectTargetOnly() (TargetSelectionResult, error) { + return c.promptSelectTarget("", "target-only selection") +} + +// promptSelectTarget is the unified method for target selection with filtering support. +// filterType can be "repository", "workspace", or "" (empty for both). +// context is used for logging purposes. +func (c *realCodeManager) promptSelectTarget(filterType, context string) (TargetSelectionResult, error) { + // Prepare parameters for hooks + params := map[string]interface{}{ + "showWorktreeLabel": false, + } + + var selectedName, selectedType string + err := c.executeWithHooks(consts.PromptSelectTarget, params, func() error { + if c.deps.Logger != nil { + c.deps.Logger.Logf("Building target choices for %s", context) + } + + // Build choices based on filter type + choices, err := c.buildTargetChoices(false, filterType) + if err != nil { + return fmt.Errorf("failed to build target choices: %w", err) + } + + if len(choices) == 0 { + return c.getNoChoicesError(filterType) + } + + if c.deps.Logger != nil { + c.deps.Logger.Logf("Prompting user to select target from %d choices", len(choices)) + } + + // Use the prompt package to get user selection + selected, err := c.deps.Prompt.PromptSelectTarget(choices, false) + if err != nil { + return fmt.Errorf("failed to get target selection: %w", err) + } + + selectedName = selected.Name + selectedType = selected.Type + + if c.deps.Logger != nil { + c.deps.Logger.Logf("User selected %s: %s", selectedType, selectedName) + } + + return nil + }) + + if err != nil { + return TargetSelectionResult{}, err + } + + return TargetSelectionResult{ + Name: selectedName, + Type: selectedType, + Worktree: "", // No worktree for target-only selection + }, nil +} + +// getNoChoicesError returns an appropriate error message based on the filter type. +func (c *realCodeManager) getNoChoicesError(filterType string) error { + switch filterType { + case prompt.TargetRepository: + return fmt.Errorf("no repositories available for selection") + case prompt.TargetWorkspace: + return fmt.Errorf("no workspaces available for selection") + default: + return fmt.Errorf("no repositories or workspaces available for selection") + } +} + +// buildTargetChoices builds a list of target choices from repositories and workspaces. +// filterType can be "repository", "workspace", or "" (empty for both). +func (c *realCodeManager) buildTargetChoices(showWorktreeLabel bool, filterType string) ([]prompt.TargetChoice, error) { + var choices []prompt.TargetChoice + + // Add repositories (if not filtering to workspaces only) + if filterType == "" || filterType == prompt.TargetRepository { + repoChoices, err := c.buildRepositoryChoices(showWorktreeLabel) + if err != nil { + return nil, fmt.Errorf("failed to build repository choices: %w", err) + } + choices = append(choices, repoChoices...) + } + + // Add workspaces (if not filtering to repositories only) + if filterType == "" || filterType == prompt.TargetWorkspace { + workspaceChoices, err := c.buildWorkspaceChoices(showWorktreeLabel) + if err != nil { + return nil, fmt.Errorf("failed to build workspace choices: %w", err) + } + choices = append(choices, workspaceChoices...) + } + + // Sort choices + c.sortChoices(choices, filterType) + + return choices, nil +} + +// buildRepositoryChoices builds choices for repositories. +func (c *realCodeManager) buildRepositoryChoices(showWorktreeLabel bool) ([]prompt.TargetChoice, error) { + repositories, err := c.ListRepositories() + if err != nil { + return nil, err + } + + var choices []prompt.TargetChoice + for _, repo := range repositories { + choice := prompt.TargetChoice{ + Type: prompt.TargetRepository, + Name: repo.Name, + } + + if showWorktreeLabel { + choice.Worktree = c.getFirstWorktreeForRepositorySafe(repo.Name) + } + + choices = append(choices, choice) + } + + return choices, nil +} + +// buildWorkspaceChoices builds choices for workspaces. +func (c *realCodeManager) buildWorkspaceChoices(showWorktreeLabel bool) ([]prompt.TargetChoice, error) { + workspaces, err := c.ListWorkspaces() + if err != nil { + return nil, err + } + + var choices []prompt.TargetChoice + for _, workspace := range workspaces { + choice := prompt.TargetChoice{ + Type: prompt.TargetWorkspace, + Name: workspace.Name, + } + + if showWorktreeLabel { + choice.Worktree = c.getFirstWorktreeForWorkspaceSafe(workspace.Name) + } + + choices = append(choices, choice) + } + + return choices, nil +} + +// sortChoices sorts the choices based on filter type. +func (c *realCodeManager) sortChoices(choices []prompt.TargetChoice, filterType string) { + if filterType == "" { + // Mixed types: sort by type first, then by name + sort.Slice(choices, func(i, j int) bool { + if choices[i].Type != choices[j].Type { + return choices[i].Type < choices[j].Type + } + return choices[i].Name < choices[j].Name + }) + } else { + // Single type: sort by name only + sort.Slice(choices, func(i, j int) bool { + return choices[i].Name < choices[j].Name + }) + } +} + +// promptSelectRepositoryOnly prompts the user to select a repository only. +func (c *realCodeManager) promptSelectRepositoryOnly() (TargetSelectionResult, error) { + return c.promptSelectTarget(prompt.TargetRepository, "repository-only selection") +} + +// promptSelectWorkspaceOnly prompts the user to select a workspace only. +func (c *realCodeManager) promptSelectWorkspaceOnly() (TargetSelectionResult, error) { + return c.promptSelectTarget(prompt.TargetWorkspace, "workspace-only selection") +} + +// getFirstWorktreeForRepository gets the first worktree alphabetically for a repository. +func (c *realCodeManager) getFirstWorktreeForRepository(repoName string) (string, error) { + // Get worktrees for the repository + worktrees, err := c.ListWorktrees(ListWorktreesOpts{ + RepositoryName: repoName, + }) + if err != nil { + return "", err + } + + if len(worktrees) == 0 { + return "", nil + } + + // Sort worktrees by branch name and return the first one + sort.Slice(worktrees, func(i, j int) bool { + return worktrees[i].Branch < worktrees[j].Branch + }) + + return worktrees[0].Branch, nil +} + +// getFirstWorktreeForWorkspace gets the first worktree alphabetically for a workspace. +func (c *realCodeManager) getFirstWorktreeForWorkspace(workspaceName string) (string, error) { + // Get worktrees for the workspace + worktrees, err := c.ListWorktrees(ListWorktreesOpts{ + WorkspaceName: workspaceName, + }) + if err != nil { + return "", err + } + + if len(worktrees) == 0 { + return "", nil + } + + // Sort worktrees by branch name and return the first one + sort.Slice(worktrees, func(i, j int) bool { + return worktrees[i].Branch < worktrees[j].Branch + }) + + return worktrees[0].Branch, nil +} + +// getFirstWorktreeForRepositorySafe gets the first worktree for a repository, returning empty string on error. +func (c *realCodeManager) getFirstWorktreeForRepositorySafe(repoName string) string { + worktree, err := c.getFirstWorktreeForRepository(repoName) + if err != nil { + if c.deps.Logger != nil { + c.deps.Logger.Logf("Failed to get worktree for repository %s: %v", repoName, err) + } + return "" + } + return worktree +} + +// getFirstWorktreeForWorkspaceSafe gets the first worktree for a workspace, returning empty string on error. +func (c *realCodeManager) getFirstWorktreeForWorkspaceSafe(workspaceName string) string { + worktree, err := c.getFirstWorktreeForWorkspace(workspaceName) + if err != nil { + if c.deps.Logger != nil { + c.deps.Logger.Logf("Failed to get worktree for workspace %s: %v", workspaceName, err) + } + return "" + } + return worktree +} diff --git a/pkg/code-manager/prompt_select_test.go b/pkg/code-manager/prompt_select_test.go new file mode 100644 index 0000000..44d9079 --- /dev/null +++ b/pkg/code-manager/prompt_select_test.go @@ -0,0 +1,222 @@ +//go:build unit + +package codemanager + +import ( + "testing" + + "github.com/lerenn/code-manager/pkg/config" + configmocks "github.com/lerenn/code-manager/pkg/config/mocks" + "github.com/lerenn/code-manager/pkg/dependencies" + fsmocks "github.com/lerenn/code-manager/pkg/fs/mocks" + gitmocks "github.com/lerenn/code-manager/pkg/git/mocks" + "github.com/lerenn/code-manager/pkg/logger" + "github.com/lerenn/code-manager/pkg/mode/repository" + repositorymocks "github.com/lerenn/code-manager/pkg/mode/repository/mocks" + "github.com/lerenn/code-manager/pkg/mode/workspace" + workspacemocks "github.com/lerenn/code-manager/pkg/mode/workspace/mocks" + "github.com/lerenn/code-manager/pkg/prompt" + promptmocks "github.com/lerenn/code-manager/pkg/prompt/mocks" + "github.com/lerenn/code-manager/pkg/status" + statusmocks "github.com/lerenn/code-manager/pkg/status/mocks" + "github.com/lerenn/code-manager/pkg/worktree" + worktreemocks "github.com/lerenn/code-manager/pkg/worktree/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.uber.org/mock/gomock" +) + +func TestPromptSelectTarget_NoChoices(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Create mock dependencies + mockPrompt := promptmocks.NewMockPrompter(ctrl) + mockStatus := statusmocks.NewMockManager(ctrl) + mockConfig := configmocks.NewMockManager(ctrl) + mockFS := fsmocks.NewMockFS(ctrl) + mockGit := gitmocks.NewMockGit(ctrl) + + // Setup mocks + testConfig := config.Config{ + RepositoriesDir: "/test/repos", + WorkspacesDir: "/test/workspaces", + StatusFile: "/test/status.yaml", + } + mockConfig.EXPECT().GetConfigWithFallback().Return(testConfig, nil) + mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{}, nil) + mockStatus.EXPECT().ListWorkspaces().Return(map[string]status.Workspace{}, nil) + + // Create CM instance + cm := &realCodeManager{ + deps: dependencies.New(). + WithFS(mockFS). + WithGit(mockGit). + WithConfig(mockConfig). + WithStatusManager(mockStatus). + WithLogger(logger.NewNoopLogger()). + WithPrompt(mockPrompt). + WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + return repositorymocks.NewMockRepository(ctrl) + }). + WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { + return workspacemocks.NewMockWorkspace(ctrl) + }). + WithWorktreeProvider(func(params worktree.NewWorktreeParams) worktree.Worktree { + return worktreemocks.NewMockWorktree(ctrl) + }), + } + + // Test with no choices available + _, err := cm.promptSelectTargetOnly() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no repositories or workspaces available") +} + +func TestPromptSelectTarget_WithChoices(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Create mock dependencies + mockPrompt := promptmocks.NewMockPrompter(ctrl) + mockStatus := statusmocks.NewMockManager(ctrl) + mockConfig := configmocks.NewMockManager(ctrl) + mockFS := fsmocks.NewMockFS(ctrl) + mockGit := gitmocks.NewMockGit(ctrl) + + // Setup repositories and workspaces + repositories := map[string]status.Repository{ + "test-repo": { + Path: "/path/to/test-repo", + }, + } + workspaces := map[string]status.Workspace{ + "test-workspace": { + Repositories: []string{"test-repo"}, + }, + } + + // Setup mocks + testConfig := config.Config{ + RepositoriesDir: "/test/repos", + WorkspacesDir: "/test/workspaces", + StatusFile: "/test/status.yaml", + } + mockConfig.EXPECT().GetConfigWithFallback().Return(testConfig, nil) + mockFS.EXPECT().IsPathWithinBase("/test/repos", "/path/to/test-repo").Return(true, nil) + mockStatus.EXPECT().ListRepositories().Return(repositories, nil) + mockStatus.EXPECT().ListWorkspaces().Return(workspaces, nil) + + // Mock the prompt selection + expectedChoice := prompt.TargetChoice{ + Type: prompt.TargetRepository, + Name: "test-repo", + } + mockPrompt.EXPECT().PromptSelectTarget(mock.MatchedBy(func(choices []prompt.TargetChoice) bool { + return len(choices) == 2 && choices[0].Name == "test-repo" && choices[1].Name == "test-workspace" + }), false).Return(expectedChoice, nil) + + // Create CM instance + cm := &realCodeManager{ + deps: dependencies.New(). + WithFS(mockFS). + WithGit(mockGit). + WithConfig(mockConfig). + WithStatusManager(mockStatus). + WithLogger(logger.NewNoopLogger()). + WithPrompt(mockPrompt). + WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + return repositorymocks.NewMockRepository(ctrl) + }). + WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { + return workspacemocks.NewMockWorkspace(ctrl) + }). + WithWorktreeProvider(func(params worktree.NewWorktreeParams) worktree.Worktree { + return worktreemocks.NewMockWorktree(ctrl) + }), + } + + // Test selection + result, err := cm.promptSelectTargetOnly() + assert.NoError(t, err) + assert.Equal(t, "test-repo", result.Name) + assert.Equal(t, "repository", result.Type) +} + +func TestBuildTargetChoices(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Create mock dependencies + mockStatus := statusmocks.NewMockManager(ctrl) + mockConfig := configmocks.NewMockManager(ctrl) + mockFS := fsmocks.NewMockFS(ctrl) + mockGit := gitmocks.NewMockGit(ctrl) + + // Setup repositories and workspaces + repositories := map[string]status.Repository{ + "alpha-repo": { + Path: "/path/to/alpha-repo", + }, + "beta-repo": { + Path: "/path/to/beta-repo", + }, + } + workspaces := map[string]status.Workspace{ + "gamma-workspace": { + Repositories: []string{"alpha-repo"}, + }, + "delta-workspace": { + Repositories: []string{"beta-repo"}, + }, + } + + // Setup mocks + testConfig := config.Config{ + RepositoriesDir: "/test/repos", + WorkspacesDir: "/test/workspaces", + StatusFile: "/test/status.yaml", + } + mockConfig.EXPECT().GetConfigWithFallback().Return(testConfig, nil) + mockFS.EXPECT().IsPathWithinBase("/test/repos", "/path/to/alpha-repo").Return(true, nil) + mockFS.EXPECT().IsPathWithinBase("/test/repos", "/path/to/beta-repo").Return(true, nil) + mockStatus.EXPECT().ListRepositories().Return(repositories, nil) + mockStatus.EXPECT().ListWorkspaces().Return(workspaces, nil) + + // Create CM instance + cm := &realCodeManager{ + deps: dependencies.New(). + WithFS(mockFS). + WithGit(mockGit). + WithConfig(mockConfig). + WithStatusManager(mockStatus). + WithLogger(logger.NewNoopLogger()). + WithPrompt(promptmocks.NewMockPrompter(ctrl)). + WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + return repositorymocks.NewMockRepository(ctrl) + }). + WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { + return workspacemocks.NewMockWorkspace(ctrl) + }). + WithWorktreeProvider(func(params worktree.NewWorktreeParams) worktree.Worktree { + return worktreemocks.NewMockWorktree(ctrl) + }), + } + + // Test building choices + choices, err := cm.buildTargetChoices(false, "") + assert.NoError(t, err) + assert.Len(t, choices, 4) // 2 repos + 2 workspaces + + // Check that repositories come first (sorted by type) + assert.Equal(t, prompt.TargetRepository, choices[0].Type) + assert.Equal(t, "alpha-repo", choices[0].Name) + assert.Equal(t, prompt.TargetRepository, choices[1].Type) + assert.Equal(t, "beta-repo", choices[1].Name) + + // Check that workspaces come after repositories + assert.Equal(t, prompt.TargetWorkspace, choices[2].Type) + assert.Equal(t, "delta-workspace", choices[2].Name) + assert.Equal(t, prompt.TargetWorkspace, choices[3].Type) + assert.Equal(t, "gamma-workspace", choices[3].Name) +} diff --git a/pkg/code-manager/repo_delete.go b/pkg/code-manager/repo_delete.go index 1bb9a06..f3679e8 100644 --- a/pkg/code-manager/repo_delete.go +++ b/pkg/code-manager/repo_delete.go @@ -17,6 +17,22 @@ type DeleteRepositoryParams struct { // DeleteRepository deletes a repository and all associated resources. func (c *realCodeManager) DeleteRepository(params DeleteRepositoryParams) error { + // Validate repository name first (before interactive selection) + if params.RepositoryName != "" { + if err := c.validateRepositoryName(params.RepositoryName); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidRepositoryName, err) + } + } + + // Handle interactive selection if no repository name is provided + if params.RepositoryName == "" { + result, err := c.promptSelectRepositoryOnly() + if err != nil { + return fmt.Errorf("failed to select repository: %w", err) + } + params.RepositoryName = result.Name + } + return c.executeWithHooks(consts.DeleteRepository, map[string]interface{}{ "repository_name": params.RepositoryName, "force": params.Force, @@ -29,11 +45,6 @@ func (c *realCodeManager) DeleteRepository(params DeleteRepositoryParams) error func (c *realCodeManager) deleteRepository(params DeleteRepositoryParams) error { c.VerbosePrint("Deleting repository: %s", params.RepositoryName) - // Validate repository name - if err := c.validateRepositoryName(params.RepositoryName); err != nil { - return fmt.Errorf("%w: %w", ErrInvalidRepositoryName, err) - } - // Check if repository is part of any workspace if err := c.validateRepositoryNotInWorkspace(params.RepositoryName); err != nil { return err diff --git a/pkg/code-manager/workspace_delete.go b/pkg/code-manager/workspace_delete.go index 4d99c02..8dc461a 100644 --- a/pkg/code-manager/workspace_delete.go +++ b/pkg/code-manager/workspace_delete.go @@ -12,6 +12,22 @@ import ( // DeleteWorkspace deletes a workspace and all associated resources. func (c *realCodeManager) DeleteWorkspace(params DeleteWorkspaceParams) error { + // Validate workspace name first (before interactive selection) + if params.WorkspaceName != "" { + if err := c.validateWorkspaceName(params.WorkspaceName); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidWorkspaceName, err) + } + } + + // Handle interactive selection if no workspace name is provided + if params.WorkspaceName == "" { + result, err := c.promptSelectWorkspaceOnly() + if err != nil { + return fmt.Errorf("failed to select workspace: %w", err) + } + params.WorkspaceName = result.Name + } + return c.executeWithHooks("delete_workspace", map[string]interface{}{ "workspace_name": params.WorkspaceName, "force": params.Force, @@ -24,11 +40,6 @@ func (c *realCodeManager) DeleteWorkspace(params DeleteWorkspaceParams) error { func (c *realCodeManager) deleteWorkspace(params DeleteWorkspaceParams) error { c.VerbosePrint("Deleting workspace: %s", params.WorkspaceName) - // Validate workspace name - if err := c.validateWorkspaceName(params.WorkspaceName); err != nil { - return fmt.Errorf("%w: %w", ErrInvalidWorkspaceName, err) - } - // Get workspace and worktrees workspace, worktrees, err := c.getWorkspaceAndWorktrees(params.WorkspaceName) if err != nil { @@ -228,56 +239,64 @@ func (c *realCodeManager) deleteSingleWorkspaceWorktree( ) error { c.VerbosePrint(" Deleting worktree: %s/%s", worktree.Remote, worktree.Branch) - // Find which repository this worktree belongs to - repoURL, repoPath, err := c.findWorktreeRepository(workspace, worktree) - if err != nil { - return err - } - // Get config from ConfigManager cfg, err := c.deps.Config.GetConfigWithFallback() if err != nil { return fmt.Errorf("failed to get config: %w", err) } - // Get worktree path and remove from Git - worktreePath := filepath.Join(cfg.RepositoriesDir, repoURL, worktree.Remote, worktree.Branch) - if err := c.removeWorktreeFromGit(repoPath, worktreePath, worktree, force); err != nil { - return err - } + // Remove worktree from all repositories in the workspace that contain it + var deletionErrors []error + for _, repoURL := range workspace.Repositories { + // Check if this repository has the worktree + repo, err := c.deps.StatusManager.GetRepository(repoURL) + if err != nil { + c.VerbosePrint(" ⚠ Skipping repository %s: %v", repoURL, err) + continue + } - // Remove worktree from status - if err := c.removeWorktreeFromStatus(repoURL, worktree); err != nil { - return err - } + // Check if worktree exists in this repository + hasWorktree := false + for _, repoWorktree := range repo.Worktrees { + if repoWorktree.Branch == worktree.Branch { + hasWorktree = true + break + } + } - c.VerbosePrint(" ✓ Deleted worktree: %s/%s", worktree.Remote, worktree.Branch) - return nil -} + if !hasWorktree { + continue + } -// findWorktreeRepository finds the repository that contains a specific worktree. -func (c *realCodeManager) findWorktreeRepository( - workspace *status.Workspace, - worktree status.WorktreeInfo, -) (string, string, error) { - for _, currentRepoURL := range workspace.Repositories { - // Get repository to check its worktrees - repo, err := c.deps.StatusManager.GetRepository(currentRepoURL) - if err != nil { - c.VerbosePrint(" ⚠ Skipping repository %s: %v", currentRepoURL, err) + c.VerbosePrint(" Found worktree in repository: %s", repoURL) + + // Get repository path for this specific repository + repoPath := repo.Path + worktreePath := filepath.Join(cfg.RepositoriesDir, repoURL, worktree.Remote, worktree.Branch) + + // Remove worktree from Git + if err := c.removeWorktreeFromGit(repoPath, worktreePath, worktree, force); err != nil { + deletionErrors = append(deletionErrors, fmt.Errorf("failed to remove worktree from repository %s: %w", repoURL, err)) continue } - // Check if this worktree belongs to this repository - // The worktrees are stored with "remote:branch" as the key - worktreeKey := fmt.Sprintf("%s:%s", worktree.Remote, worktree.Branch) - if _, exists := repo.Worktrees[worktreeKey]; exists { - return currentRepoURL, repo.Path, nil + // Remove worktree from status + if err := c.removeWorktreeFromStatus(repoURL, worktree); err != nil { + deletionErrors = append(deletionErrors, fmt.Errorf( + "failed to remove worktree from status for repository %s: %w", repoURL, err)) + continue } + + c.VerbosePrint(" ✓ Deleted worktree from repository: %s", repoURL) } - c.VerbosePrint(" ⚠ Worktree %s/%s not found in any workspace repository", worktree.Remote, worktree.Branch) - return "", "", fmt.Errorf("worktree %s/%s not found in any workspace repository", worktree.Remote, worktree.Branch) + if len(deletionErrors) > 0 { + return fmt.Errorf("errors occurred while deleting worktree %s/%s: %v", + worktree.Remote, worktree.Branch, deletionErrors) + } + + c.VerbosePrint(" ✓ Deleted worktree: %s/%s", worktree.Remote, worktree.Branch) + return nil } // removeWorktreeFromGit removes a worktree from Git. diff --git a/pkg/code-manager/workspace_delete_test.go b/pkg/code-manager/workspace_delete_test.go index 0691866..0c45db5 100644 --- a/pkg/code-manager/workspace_delete_test.go +++ b/pkg/code-manager/workspace_delete_test.go @@ -261,6 +261,8 @@ func TestDeleteWorkspace_InvalidName(t *testing.T) { mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusmocks.NewMockManager(ctrl) mockConfig := configmocks.NewMockManager(ctrl) + mockPrompt := promptmocks.NewMockPrompter(ctrl) + mockHookManager := hooksMocks.NewMockHookManagerInterface(ctrl) cm := &realCodeManager{ deps: dependencies.New(). @@ -268,11 +270,18 @@ func TestDeleteWorkspace_InvalidName(t *testing.T) { WithGit(mockGit). WithConfig(mockConfig). WithStatusManager(mockStatus). - WithLogger(logger.NewNoopLogger()), + WithLogger(logger.NewNoopLogger()). + WithPrompt(mockPrompt). + WithHookManager(mockHookManager), } + // Note: No baseline expectations needed since WorkspaceName is provided (no interactive selection) + // Only specific expectations for this test + mockConfig.EXPECT().GetConfigWithFallback().Return(config.Config{}, nil).AnyTimes() + mockFS.EXPECT().Exists(".code-workspace").Return(false, nil).AnyTimes() + params := DeleteWorkspaceParams{ - WorkspaceName: "", // Empty name + WorkspaceName: "invalid/name", // Invalid name Force: true, } diff --git a/pkg/code-manager/worktrees_create.go b/pkg/code-manager/worktrees_create.go index 0b61e7e..3e3999b 100644 --- a/pkg/code-manager/worktrees_create.go +++ b/pkg/code-manager/worktrees_create.go @@ -11,6 +11,7 @@ import ( "github.com/lerenn/code-manager/pkg/mode" repo "github.com/lerenn/code-manager/pkg/mode/repository" ws "github.com/lerenn/code-manager/pkg/mode/workspace" + "github.com/lerenn/code-manager/pkg/prompt" ) // CreateWorkTreeOpts contains optional parameters for CreateWorkTree. @@ -28,66 +29,117 @@ func (c *realCodeManager) CreateWorkTree(branch string, opts ...CreateWorkTreeOp // Extract and validate options options := c.extractCreateWorkTreeOptions(opts) - // Validate that workspace and repository are not both specified - if options.WorkspaceName != "" && options.RepositoryName != "" { - return fmt.Errorf("cannot specify both WorkspaceName and RepositoryName") + // Validate target exclusivity + if err := c.validateCreateTargets(options); err != nil { + return err } - // Prepare parameters for hooks - params := map[string]interface{}{ - "branch": branch, - "issueRef": options.IssueRef, - "workspaceName": options.WorkspaceName, - "repositoryName": options.RepositoryName, - "force": options.Force, - } - if options.IDEName != "" { - params["ideName"] = options.IDEName + // Resolve target and branch interactively as needed + if err := c.resolveCreateSelection(&branch, &options); err != nil { + return err } + // Prepare parameters for hooks + params := c.prepareCreateWorkTreeParams(branch, options) + // Execute with hooks return c.executeWithHooks(consts.CreateWorkTree, params, func() error { - var worktreePath string - var err error + return c.performCreate(branch, opts, options, params) + }) +} - // Sanitize branch name - sanitizedBranch, err := c.sanitizeBranchNameForCreation(branch, options.IssueRef) - if err != nil { +// validateCreateTargets ensures only one of WorkspaceName or RepositoryName is provided. +func (c *realCodeManager) validateCreateTargets(options CreateWorkTreeOpts) error { + if options.WorkspaceName != "" && options.RepositoryName != "" { + return fmt.Errorf("cannot specify both WorkspaceName and RepositoryName") + } + + // Validate issue reference if provided + if options.IssueRef != "" { + if err := c.validateIssueReference(options.IssueRef); err != nil { return err } + } - // Log if branch name was sanitized - if sanitizedBranch != branch && c.deps.Logger != nil { - c.deps.Logger.Logf("Branch name sanitized: %s -> %s", branch, sanitizedBranch) - } + return nil +} - c.VerbosePrint("Starting CM execution for branch: %s (sanitized: %s)", branch, sanitizedBranch) +// validateIssueReference validates that the issue reference format is valid. +func (c *realCodeManager) validateIssueReference(issueRef string) error { + // Create a temporary forge instance to validate the issue reference + forgeInstance := forge.NewGitHub() - // 1. First determine the mode (workspace or repository) - projectType, err := c.detectProjectMode(options.WorkspaceName, options.RepositoryName) - if err != nil { - return fmt.Errorf("failed to detect project mode: %w", err) + // Try to parse the issue reference + _, err := forgeInstance.ParseIssueReference(issueRef) + if err != nil { + // Check if it's a context-required error (issue number only) + if errors.Is(err, issue.ErrIssueNumberRequiresContext) { + // This is valid, but requires repository context + return nil } + // Return the validation error + return err + } - // 2. Then handle creation based on mode and flags - worktreePath, err = c.handleWorktreeCreation(handleWorktreeCreationParams{ - ProjectType: projectType, - SanitizedBranch: sanitizedBranch, - IssueRef: options.IssueRef, - WorkspaceName: options.WorkspaceName, - RepositoryName: options.RepositoryName, - Options: options, - Opts: opts, - }) - if err != nil { + return nil +} + +// resolveCreateSelection handles interactive target selection and branch prompting when needed. +func (c *realCodeManager) resolveCreateSelection(branch *string, options *CreateWorkTreeOpts) error { + if options.WorkspaceName == "" && options.RepositoryName == "" { + if err := c.handleInteractiveTargetSelection(options); err != nil { return err } + } + if err := c.handleBranchNameInput(branch, options.IssueRef); err != nil { + return err + } + return nil +} - // Set worktreePath in params for the IDE opening hook - params["worktreePath"] = worktreePath +// performCreate computes sanitized branch, detects mode and performs creation. +func (c *realCodeManager) performCreate( + branch string, + opts []CreateWorkTreeOpts, + options CreateWorkTreeOpts, + params map[string]interface{}, +) error { + // Sanitize branch name + sanitizedBranch, err := c.sanitizeBranchNameForCreation(branch, options.IssueRef) + if err != nil { + return err + } - return nil + // Log if branch name was sanitized + if sanitizedBranch != branch && c.deps.Logger != nil { + c.deps.Logger.Logf("Branch name sanitized: %s -> %s", branch, sanitizedBranch) + } + + c.VerbosePrint("Starting CM execution for branch: %s (sanitized: %s)", branch, sanitizedBranch) + + // Determine the mode (workspace or repository) + projectType, err := c.detectProjectMode(options.WorkspaceName, options.RepositoryName) + if err != nil { + return fmt.Errorf("failed to detect project mode: %w", err) + } + + // Handle creation based on mode and flags + worktreePath, err := c.handleWorktreeCreation(handleWorktreeCreationParams{ + ProjectType: projectType, + SanitizedBranch: sanitizedBranch, + IssueRef: options.IssueRef, + WorkspaceName: options.WorkspaceName, + RepositoryName: options.RepositoryName, + Options: options, + Opts: opts, }) + if err != nil { + return err + } + + // Set worktreePath in params for the IDE opening hook + params["worktreePath"] = worktreePath + return nil } // extractCreateWorkTreeOptions extracts and merges options from the variadic parameter. @@ -358,3 +410,50 @@ func (c *realCodeManager) translateIssueError(err error) error { // Return the original error if no translation is needed return err } + +// handleInteractiveTargetSelection handles the interactive selection logic for target selection. +func (c *realCodeManager) handleInteractiveTargetSelection(options *CreateWorkTreeOpts) error { + result, err := c.promptSelectTargetOnly() + if err != nil { + return fmt.Errorf("failed to select target: %w", err) + } + + switch result.Type { + case prompt.TargetWorkspace: + options.WorkspaceName = result.Name + case prompt.TargetRepository: + options.RepositoryName = result.Name + default: + return fmt.Errorf("invalid target type selected: %s", result.Type) + } + + return nil +} + +// handleBranchNameInput handles interactive branch name input if not provided. +func (c *realCodeManager) handleBranchNameInput(branch *string, issueRef string) error { + if *branch == "" && issueRef == "" { + branchName, err := c.deps.Prompt.PromptForBranchName() + if err != nil { + return fmt.Errorf("failed to get branch name: %w", err) + } + *branch = branchName + } + return nil +} + +// prepareCreateWorkTreeParams prepares the parameters map for CreateWorkTree hooks. +func (c *realCodeManager) prepareCreateWorkTreeParams( + branch string, options CreateWorkTreeOpts) map[string]interface{} { + params := map[string]interface{}{ + "branch": branch, + "issueRef": options.IssueRef, + "workspaceName": options.WorkspaceName, + "repositoryName": options.RepositoryName, + "force": options.Force, + } + if options.IDEName != "" { + params["ideName"] = options.IDEName + } + return params +} diff --git a/pkg/code-manager/worktrees_create_test.go b/pkg/code-manager/worktrees_create_test.go index ab178eb..1c1412a 100644 --- a/pkg/code-manager/worktrees_create_test.go +++ b/pkg/code-manager/worktrees_create_test.go @@ -16,6 +16,7 @@ import ( repositoryMocks "github.com/lerenn/code-manager/pkg/mode/repository/mocks" "github.com/lerenn/code-manager/pkg/mode/workspace" workspaceMocks "github.com/lerenn/code-manager/pkg/mode/workspace/mocks" + "github.com/lerenn/code-manager/pkg/prompt" promptMocks "github.com/lerenn/code-manager/pkg/prompt/mocks" "github.com/lerenn/code-manager/pkg/status" statusMocks "github.com/lerenn/code-manager/pkg/status/mocks" @@ -25,6 +26,22 @@ import ( "go.uber.org/mock/gomock" ) +// setBaselineExpectationsCreate sets common expectations for interactive flows in create tests. +func setBaselineExpectationsCreate( + mockHookManager *hooksMocks.MockHookManagerInterface, + mockStatus *statusMocks.MockManager, + mockPrompt *promptMocks.MockPrompter, + mockFS *fsmocks.MockFS, +) { + mockHookManager.EXPECT().ExecutePreHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecutePostHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecuteErrorHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{"test-repo": {}}, nil).AnyTimes() + mockStatus.EXPECT().ListWorkspaces().Return(map[string]status.Workspace{}, nil).AnyTimes() + mockPrompt.EXPECT().PromptSelectTarget(gomock.Any(), gomock.Any()).Return(prompt.TargetChoice{Type: prompt.TargetRepository, Name: "test-repo"}, nil).AnyTimes() + mockFS.EXPECT().IsPathWithinBase(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() +} + func TestCM_CreateWorkTree_SingleRepository(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -60,16 +77,14 @@ func TestCM_CreateWorkTree_SingleRepository(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) + setBaselineExpectationsCreate(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock repository detection and worktree creation mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() mockRepository.EXPECT().Validate().Return(nil) mockRepository.EXPECT().CreateWorktree("test-branch", gomock.Any()).Return("/test/base/path/test-repo/origin/test-branch", nil) - err = cm.CreateWorkTree("test-branch") + err = cm.CreateWorkTree("test-branch", CreateWorkTreeOpts{RepositoryName: "test-repo"}) assert.NoError(t, err) } @@ -108,9 +123,8 @@ func TestCM_CreateWorkTreeWithIDE(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsCreate(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock repository detection and worktree creation mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -118,7 +132,7 @@ func TestCM_CreateWorkTreeWithIDE(t *testing.T) { mockRepository.EXPECT().CreateWorktree("test-branch", gomock.Any()).Return("/test/base/path/test-repo/origin/test-branch", nil) // Note: IDE opening is now handled by the hook system, not tested here - err = cm.CreateWorkTree("test-branch", CreateWorkTreeOpts{IDEName: "vscode"}) + err = cm.CreateWorkTree("test-branch", CreateWorkTreeOpts{RepositoryName: "test-repo", IDEName: "vscode"}) assert.NoError(t, err) } @@ -157,9 +171,8 @@ func TestCM_CreateWorkTree_WorkspaceMode(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsCreate(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock workspace worktree creation workspaceName := "test-workspace" @@ -206,9 +219,8 @@ func TestCM_CreateWorkTree_WorkspaceModeWithIDE(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.CreateWorkTree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsCreate(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock workspace worktree creation workspaceName := "test-workspace" diff --git a/pkg/code-manager/worktrees_delete.go b/pkg/code-manager/worktrees_delete.go index 050b740..a1a79bb 100644 --- a/pkg/code-manager/worktrees_delete.go +++ b/pkg/code-manager/worktrees_delete.go @@ -8,6 +8,7 @@ import ( "github.com/lerenn/code-manager/pkg/mode" repo "github.com/lerenn/code-manager/pkg/mode/repository" ws "github.com/lerenn/code-manager/pkg/mode/workspace" + "github.com/lerenn/code-manager/pkg/prompt" ) // DeleteWorktreeOpts contains optional parameters for DeleteWorkTree. @@ -21,43 +22,74 @@ func (c *realCodeManager) DeleteWorkTree(branch string, force bool, opts ...Dele // Parse options options := c.extractDeleteWorktreeOptions(opts) - // Validate that workspace and repository are not both specified - if options.WorkspaceName != "" && options.RepositoryName != "" { - return fmt.Errorf("cannot specify both WorkspaceName and RepositoryName") + // Validate exclusivity of targets + if err := c.validateDeleteTargets(options); err != nil { + return err } - // Prepare parameters for hooks - params := map[string]interface{}{ - "branch": branch, - "force": force, - "workspace_name": options.WorkspaceName, - "repository_name": options.RepositoryName, + // Resolve target/branch interactively if needed + updatedBranch, err := c.resolveDeleteSelection(branch, &options) + if err != nil { + return err } + branch = updatedBranch + + // Prepare parameters for hooks + params := c.prepareDeleteWorkTreeParams(branch, force, options) // Execute with hooks return c.executeWithHooks(consts.DeleteWorkTree, params, func() error { - c.VerbosePrint("Deleting worktree for branch: %s (force: %t)", branch, force) + return c.performDelete(branch, force, options) + }) +} + +// validateDeleteTargets ensures only one of WorkspaceName or RepositoryName is provided. +func (c *realCodeManager) validateDeleteTargets(options DeleteWorktreeOpts) error { + if options.WorkspaceName != "" && options.RepositoryName != "" { + return fmt.Errorf("cannot specify both WorkspaceName and RepositoryName") + } + return nil +} - // Detect project mode and delete accordingly - projectType, err := c.detectProjectMode(options.WorkspaceName, options.RepositoryName) +// resolveDeleteSelection handles interactive selection and branch prompting when needed. +func (c *realCodeManager) resolveDeleteSelection(branch string, options *DeleteWorktreeOpts) (string, error) { + if options.WorkspaceName == "" && options.RepositoryName == "" { + selectedBranch, err := c.handleInteractiveTargetSelectionForDelete(branch, options) if err != nil { - return fmt.Errorf("failed to detect project mode: %w", err) + return "", err } + return selectedBranch, nil + } + if branch == "" { + if err := c.handleBranchNameInputForDelete(&branch); err != nil { + return "", err + } + } + return branch, nil +} - switch projectType { - case mode.ModeSingleRepo: - if options.RepositoryName != "" { - return c.deleteRepositoryWorktree(options.RepositoryName, branch, force) - } - return c.handleRepositoryDeleteMode(branch, force) - case mode.ModeWorkspace: - return c.handleWorkspaceDeleteMode(branch, force) - case mode.ModeNone: - return ErrNoGitRepositoryOrWorkspaceFound - default: - return fmt.Errorf("unknown project type") +// performDelete detects project mode and performs the actual deletion. +func (c *realCodeManager) performDelete(branch string, force bool, options DeleteWorktreeOpts) error { + c.VerbosePrint("Deleting worktree for branch: %s (force: %t)", branch, force) + + projectType, err := c.detectProjectMode(options.WorkspaceName, options.RepositoryName) + if err != nil { + return fmt.Errorf("failed to detect project mode: %w", err) + } + + switch projectType { + case mode.ModeSingleRepo: + if options.RepositoryName != "" { + return c.deleteRepositoryWorktree(options.RepositoryName, branch, force) } - }) + return c.handleRepositoryDeleteMode(branch, force) + case mode.ModeWorkspace: + return c.handleWorkspaceDeleteMode(branch, force) + case mode.ModeNone: + return ErrNoGitRepositoryOrWorkspaceFound + default: + return fmt.Errorf("unknown project type") + } } // handleRepositoryDeleteMode handles repository mode: validation and worktree deletion. @@ -111,7 +143,8 @@ func (c *realCodeManager) DeleteWorkTrees(branches []string, force bool) error { var errors []error for _, branch := range branches { c.VerbosePrint("Deleting worktree for branch: %s", branch) - if err := c.DeleteWorkTree(branch, force); err != nil { + // Use current repository context for DeleteWorkTrees + if err := c.DeleteWorkTree(branch, force, DeleteWorktreeOpts{RepositoryName: "."}); err != nil { c.VerbosePrint("Failed to delete worktree for branch %s: %v", branch, err) errors = append(errors, fmt.Errorf("failed to delete worktree for branch %s: %w", branch, err)) } else { @@ -192,3 +225,64 @@ func (c *realCodeManager) extractDeleteWorktreeOptions(opts []DeleteWorktreeOpts return result } + +// handleInteractiveTargetSelectionForDelete handles the interactive selection logic for target and worktree. +func (c *realCodeManager) handleInteractiveTargetSelectionForDelete( + branch string, options *DeleteWorktreeOpts) (string, error) { + if branch == "" { + // Two-step selection: first target, then worktree + result, err := c.promptSelectTargetAndWorktree() + if err != nil { + return "", fmt.Errorf("failed to select target and worktree: %w", err) + } + + switch result.Type { + case prompt.TargetWorkspace: + options.WorkspaceName = result.Name + case prompt.TargetRepository: + options.RepositoryName = result.Name + default: + return "", fmt.Errorf("invalid target type selected: %s", result.Type) + } + + return result.Worktree, nil + } + + // Single-step selection: just target (branch already provided) + result, err := c.promptSelectTargetOnly() + if err != nil { + return "", fmt.Errorf("failed to select target: %w", err) + } + + switch result.Type { + case prompt.TargetWorkspace: + options.WorkspaceName = result.Name + case prompt.TargetRepository: + options.RepositoryName = result.Name + default: + return "", fmt.Errorf("invalid target type selected: %s", result.Type) + } + + return branch, nil +} + +// handleBranchNameInputForDelete handles interactive branch name input for delete operations. +func (c *realCodeManager) handleBranchNameInputForDelete(branch *string) error { + branchName, err := c.deps.Prompt.PromptForBranchName() + if err != nil { + return fmt.Errorf("failed to get branch name: %w", err) + } + *branch = branchName + return nil +} + +// prepareDeleteWorkTreeParams prepares the parameters map for DeleteWorkTree hooks. +func (c *realCodeManager) prepareDeleteWorkTreeParams( + branch string, force bool, options DeleteWorktreeOpts) map[string]interface{} { + return map[string]interface{}{ + "branch": branch, + "force": force, + "workspace_name": options.WorkspaceName, + "repository_name": options.RepositoryName, + } +} diff --git a/pkg/code-manager/worktrees_delete_test.go b/pkg/code-manager/worktrees_delete_test.go index 1226f01..639d496 100644 --- a/pkg/code-manager/worktrees_delete_test.go +++ b/pkg/code-manager/worktrees_delete_test.go @@ -6,22 +6,39 @@ import ( "fmt" "testing" - "github.com/lerenn/code-manager/pkg/code-manager/consts" "github.com/lerenn/code-manager/pkg/config" "github.com/lerenn/code-manager/pkg/dependencies" fsmocks "github.com/lerenn/code-manager/pkg/fs/mocks" gitmocks "github.com/lerenn/code-manager/pkg/git/mocks" hooksMocks "github.com/lerenn/code-manager/pkg/hooks/mocks" - "github.com/lerenn/code-manager/pkg/mode/repository" + repo "github.com/lerenn/code-manager/pkg/mode/repository" repositoryMocks "github.com/lerenn/code-manager/pkg/mode/repository/mocks" "github.com/lerenn/code-manager/pkg/mode/workspace" workspaceMocks "github.com/lerenn/code-manager/pkg/mode/workspace/mocks" + "github.com/lerenn/code-manager/pkg/prompt" promptMocks "github.com/lerenn/code-manager/pkg/prompt/mocks" + "github.com/lerenn/code-manager/pkg/status" statusMocks "github.com/lerenn/code-manager/pkg/status/mocks" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) +// setBaselineExpectationsDelete sets common expectations for interactive flows in delete tests. +func setBaselineExpectationsDelete( + mockHookManager *hooksMocks.MockHookManagerInterface, + mockStatus *statusMocks.MockManager, + mockPrompt *promptMocks.MockPrompter, + mockFS *fsmocks.MockFS, +) { + mockHookManager.EXPECT().ExecutePreHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecutePostHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecuteErrorHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{"test-repo": {}}, nil).AnyTimes() + mockStatus.EXPECT().ListWorkspaces().Return(map[string]status.Workspace{}, nil).AnyTimes() + mockPrompt.EXPECT().PromptSelectTarget(gomock.Any(), gomock.Any()).Return(prompt.TargetChoice{Type: prompt.TargetRepository, Name: "test-repo"}, nil).AnyTimes() + mockFS.EXPECT().IsPathWithinBase(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() +} + func TestCM_DeleteWorkTree_SingleRepository(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -37,7 +54,7 @@ func TestCM_DeleteWorkTree_SingleRepository(t *testing.T) { // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ Dependencies: dependencies.New(). - WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + WithRepositoryProvider(func(params repo.NewRepositoryParams) repo.Repository { return mockRepository }). WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { @@ -52,15 +69,13 @@ func TestCM_DeleteWorkTree_SingleRepository(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) + setBaselineExpectationsDelete(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock repository detection and worktree deletion mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() mockRepository.EXPECT().DeleteWorktree("test-branch", true).Return(nil) - err = cm.DeleteWorkTree("test-branch", true) // Force deletion + err = cm.DeleteWorkTree("test-branch", true, DeleteWorktreeOpts{RepositoryName: "test-repo"}) // Force deletion assert.NoError(t, err) } @@ -85,7 +100,7 @@ func TestCM_DeleteWorkTree_NoRepository(t *testing.T) { // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ Dependencies: dependencies.New(). - WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + WithRepositoryProvider(func(params repo.NewRepositoryParams) repo.Repository { return mockRepository }). WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { @@ -100,16 +115,15 @@ func TestCM_DeleteWorkTree_NoRepository(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecuteErrorHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsDelete(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock no repository found - mockRepository.EXPECT().IsGitRepository().Return(false, nil) + mockRepository.EXPECT().IsGitRepository().Return(false, nil).AnyTimes() - err = cm.DeleteWorkTree("test-branch", true) + err = cm.DeleteWorkTree("test-branch", true, DeleteWorktreeOpts{RepositoryName: "test-repo"}) assert.Error(t, err) - assert.ErrorIs(t, err, ErrNoGitRepositoryOrWorkspaceFound) + assert.Contains(t, err.Error(), "no Git repository or workspace found") } func TestCM_DeleteWorkTrees_Success(t *testing.T) { @@ -127,7 +141,7 @@ func TestCM_DeleteWorkTrees_Success(t *testing.T) { // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ Dependencies: dependencies.New(). - WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + WithRepositoryProvider(func(params repo.NewRepositoryParams) repo.Repository { return mockRepository }). WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { @@ -144,15 +158,13 @@ func TestCM_DeleteWorkTrees_Success(t *testing.T) { branches := []string{"branch1", "branch2", "branch3"} - // Mock hook execution for each branch (3 times) - for i := 0; i < len(branches); i++ { - mockHookManager.EXPECT().ExecutePreHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - } + // Set baseline expectations for interactive flow + setBaselineExpectationsDelete(mockHookManager, mockStatus, mockPrompt, mockFS) // Mock repository detection and worktree deletion for each branch - mockRepository.EXPECT().IsGitRepository().Return(true, nil).Times(len(branches)) + // Each branch will trigger interactive selection, so we need to mock it for each branch for _, branch := range branches { + mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() mockRepository.EXPECT().DeleteWorktree(branch, true).Return(nil) } @@ -175,7 +187,7 @@ func TestCM_DeleteWorkTrees_EmptyBranches(t *testing.T) { // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ Dependencies: dependencies.New(). - WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + WithRepositoryProvider(func(params repo.NewRepositoryParams) repo.Repository { return mockRepository }). WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { @@ -210,7 +222,7 @@ func TestCM_DeleteWorkTrees_PartialFailure(t *testing.T) { // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ Dependencies: dependencies.New(). - WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + WithRepositoryProvider(func(params repo.NewRepositoryParams) repo.Repository { return mockRepository }). WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { @@ -227,20 +239,12 @@ func TestCM_DeleteWorkTrees_PartialFailure(t *testing.T) { branches := []string{"branch1", "branch2", "branch3"} - // Mock hook execution for each branch (3 times) - for i := 0; i < len(branches); i++ { - mockHookManager.EXPECT().ExecutePreHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - if i == 1 { // branch2 fails - mockHookManager.EXPECT().ExecuteErrorHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - } else { - mockHookManager.EXPECT().ExecutePostHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - } - } - - // Mock repository detection for each branch - mockRepository.EXPECT().IsGitRepository().Return(true, nil).Times(len(branches)) + // Set baseline expectations for interactive flow + setBaselineExpectationsDelete(mockHookManager, mockStatus, mockPrompt, mockFS) - // Mock worktree deletion: branch1 succeeds, branch2 fails, branch3 succeeds + // Mock repository detection and worktree deletion for each branch + // Each branch will trigger interactive selection + mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() mockRepository.EXPECT().DeleteWorktree("branch1", true).Return(nil) mockRepository.EXPECT().DeleteWorktree("branch2", true).Return(fmt.Errorf("deletion failed")) mockRepository.EXPECT().DeleteWorktree("branch3", true).Return(nil) @@ -266,7 +270,7 @@ func TestCM_DeleteWorkTrees_AllFailures(t *testing.T) { // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ Dependencies: dependencies.New(). - WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository { + WithRepositoryProvider(func(params repo.NewRepositoryParams) repo.Repository { return mockRepository }). WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace { @@ -283,16 +287,12 @@ func TestCM_DeleteWorkTrees_AllFailures(t *testing.T) { branches := []string{"branch1", "branch2"} - // Mock hook execution for each branch (2 times) - for i := 0; i < len(branches); i++ { - mockHookManager.EXPECT().ExecutePreHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecuteErrorHooks(consts.DeleteWorkTree, gomock.Any()).Return(nil) - } - - // Mock repository detection for each branch - mockRepository.EXPECT().IsGitRepository().Return(true, nil).Times(len(branches)) + // Set baseline expectations for interactive flow + setBaselineExpectationsDelete(mockHookManager, mockStatus, mockPrompt, mockFS) - // Mock worktree deletion: both fail + // Mock repository detection and worktree deletion for each branch + // Each branch will trigger interactive selection + mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() mockRepository.EXPECT().DeleteWorktree("branch1", true).Return(fmt.Errorf("deletion failed")) mockRepository.EXPECT().DeleteWorktree("branch2", true).Return(fmt.Errorf("deletion failed")) diff --git a/pkg/code-manager/worktrees_list.go b/pkg/code-manager/worktrees_list.go index be438c8..4f89153 100644 --- a/pkg/code-manager/worktrees_list.go +++ b/pkg/code-manager/worktrees_list.go @@ -7,6 +7,7 @@ import ( "github.com/lerenn/code-manager/pkg/code-manager/consts" "github.com/lerenn/code-manager/pkg/mode" "github.com/lerenn/code-manager/pkg/mode/repository" + "github.com/lerenn/code-manager/pkg/prompt" "github.com/lerenn/code-manager/pkg/status" ) @@ -26,6 +27,13 @@ func (c *realCodeManager) ListWorktrees(opts ...ListWorktreesOpts) ([]status.Wor return nil, fmt.Errorf("cannot specify both WorkspaceName and RepositoryName") } + // Handle interactive selection if neither workspace nor repository is specified + if options.WorkspaceName == "" && options.RepositoryName == "" { + if err := c.handleInteractiveTargetSelectionForList(&options); err != nil { + return nil, err + } + } + // Prepare parameters for hooks params := map[string]interface{}{ "workspace_name": options.WorkspaceName, @@ -44,29 +52,8 @@ func (c *realCodeManager) ListWorktrees(opts ...ListWorktreesOpts) ([]status.Wor return fmt.Errorf("failed to detect project mode: %w", err) } - switch projectType { - case mode.ModeSingleRepo: - if options.RepositoryName != "" { - result, err = c.listRepositoryWorktrees(options.RepositoryName) - return err - } - // Create repository instance for current directory - repoProvider := c.deps.RepositoryProvider - repoInstance := repoProvider(repository.NewRepositoryParams{ - Dependencies: c.deps, - RepositoryName: ".", - }) - worktrees, err := repoInstance.ListWorktrees() - result = worktrees - return c.translateListError(err) - case mode.ModeWorkspace: - result, err = c.listWorkspaceWorktrees(options.WorkspaceName) - return err - case mode.ModeNone: - return ErrNoGitRepositoryOrWorkspaceFound - default: - return fmt.Errorf("unknown project type") - } + result, err = c.handleWorktreeListingByMode(projectType, options) + return err }) return result, err } @@ -96,6 +83,7 @@ func (c *realCodeManager) listWorkspaceWorktreesFromWorkspace( // For workspace deletion, we need to find worktrees in ALL repositories that match the workspace's worktree references // This is because workspace creation creates worktrees in all repositories but only tracks one reference + seenBranches := make(map[string]bool) // Track which branches we've already found for _, worktreeRef := range workspace.Worktrees { c.VerbosePrint(" Looking for worktree reference: %s", worktreeRef) for _, repoURL := range workspace.Repositories { @@ -111,9 +99,10 @@ func (c *realCodeManager) listWorkspaceWorktreesFromWorkspace( // The worktrees are stored with "remote:branch" as the key, but workspace stores just "branch" // So we need to find worktrees where the branch matches for worktreeKey, worktree := range repo.Worktrees { - if worktree.Branch == worktreeRef { + if worktree.Branch == worktreeRef && !seenBranches[worktreeRef] { c.VerbosePrint(" ✓ Found worktree %s (key: %s) in repository %s", worktreeRef, worktreeKey, repoURL) workspaceWorktrees = append(workspaceWorktrees, worktree) + seenBranches[worktreeRef] = true break // Found in this repository, continue to next repository } } @@ -175,3 +164,50 @@ func (c *realCodeManager) extractListWorktreesOptions(opts []ListWorktreesOpts) return result } + +// handleInteractiveTargetSelectionForList handles the interactive selection logic for list operations. +func (c *realCodeManager) handleInteractiveTargetSelectionForList(options *ListWorktreesOpts) error { + result, err := c.promptSelectTargetOnly() + if err != nil { + return fmt.Errorf("failed to select target: %w", err) + } + + switch result.Type { + case prompt.TargetWorkspace: + options.WorkspaceName = result.Name + case prompt.TargetRepository: + options.RepositoryName = result.Name + default: + return fmt.Errorf("invalid target type selected: %s", result.Type) + } + + return nil +} + +// handleWorktreeListingByMode handles worktree listing based on the detected project mode. +func (c *realCodeManager) handleWorktreeListingByMode( + projectType mode.Mode, options ListWorktreesOpts) ([]status.WorktreeInfo, error) { + switch projectType { + case mode.ModeSingleRepo: + if options.RepositoryName != "" { + return c.listRepositoryWorktrees(options.RepositoryName) + } + // Create repository instance for current directory + repoProvider := c.deps.RepositoryProvider + repoInstance := repoProvider(repository.NewRepositoryParams{ + Dependencies: c.deps, + RepositoryName: ".", + }) + worktrees, err := repoInstance.ListWorktrees() + if err != nil { + return nil, c.translateListError(err) + } + return worktrees, nil + case mode.ModeWorkspace: + return c.listWorkspaceWorktrees(options.WorkspaceName) + case mode.ModeNone: + return nil, ErrNoGitRepositoryOrWorkspaceFound + default: + return nil, fmt.Errorf("unknown project type") + } +} diff --git a/pkg/code-manager/worktrees_list_test.go b/pkg/code-manager/worktrees_list_test.go index fabb855..b725b43 100644 --- a/pkg/code-manager/worktrees_list_test.go +++ b/pkg/code-manager/worktrees_list_test.go @@ -15,6 +15,7 @@ import ( "github.com/lerenn/code-manager/pkg/logger" "github.com/lerenn/code-manager/pkg/mode/repository" repositoryMocks "github.com/lerenn/code-manager/pkg/mode/repository/mocks" + "github.com/lerenn/code-manager/pkg/prompt" promptmocks "github.com/lerenn/code-manager/pkg/prompt/mocks" "github.com/lerenn/code-manager/pkg/status" statusmocks "github.com/lerenn/code-manager/pkg/status/mocks" @@ -22,6 +23,22 @@ import ( "go.uber.org/mock/gomock" ) +// setBaselineExpectationsList sets up baseline expectations for interactive flow in list tests. +func setBaselineExpectationsList( + mockHookManager *hooksMocks.MockHookManagerInterface, + mockStatus *statusmocks.MockManager, + mockPrompt *promptmocks.MockPrompter, + mockFS *fsmocks.MockFS, +) { + mockHookManager.EXPECT().ExecutePreHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecutePostHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecuteErrorHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{"test-repo": {}}, nil).AnyTimes() + mockStatus.EXPECT().ListWorkspaces().Return(map[string]status.Workspace{}, nil).AnyTimes() + mockPrompt.EXPECT().PromptSelectTarget(gomock.Any(), gomock.Any()).Return(prompt.TargetChoice{Type: prompt.TargetRepository, Name: "test-repo"}, nil).AnyTimes() + mockFS.EXPECT().IsPathWithinBase(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() +} + // TestListWorktrees_Success tests successful workspace worktree listing. func TestListWorktrees_Success(t *testing.T) { ctrl := gomock.NewController(t) @@ -44,6 +61,10 @@ func TestListWorktrees_Success(t *testing.T) { WithHookManager(mockHookManager), } + // Hook expectations (always needed for ListWorktrees) + mockHookManager.EXPECT().ExecutePreHooks(gomock.Any(), gomock.Any()).Return(nil) + mockHookManager.EXPECT().ExecutePostHooks(gomock.Any(), gomock.Any()).Return(nil) + // Mock workspace exists with specific worktrees workspace := &status.Workspace{ Worktrees: []string{"feature-1", "feature-2"}, @@ -72,9 +93,8 @@ func TestListWorktrees_Success(t *testing.T) { mockStatus.EXPECT().GetRepository("repo1").Return(repo1, nil).Times(2) // Called for both feature-1 and feature-2 mockStatus.EXPECT().GetRepository("repo2").Return(repo2, nil).Times(2) // Called for both feature-1 and feature-2 - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.ListWorktrees, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.ListWorktrees, gomock.Any()).Return(nil) + // Mock hook execution - no interactive selection since WorkspaceName is provided + // Note: baseline expectations handle the hook calls // Execute result, err := cm.ListWorktrees(ListWorktreesOpts{WorkspaceName: "test-workspace"}) @@ -152,7 +172,7 @@ func TestListWorktrees_EmptyWorkspace(t *testing.T) { } mockStatus.EXPECT().GetWorkspace("empty-workspace").Return(workspace, nil) - // Mock hook execution + // Mock hook execution - no interactive selection since WorkspaceName is provided mockHookManager.EXPECT().ExecutePreHooks(consts.ListWorktrees, gomock.Any()).Return(nil) mockHookManager.EXPECT().ExecutePostHooks(consts.ListWorktrees, gomock.Any()).Return(nil) @@ -206,7 +226,7 @@ func TestListWorktrees_RepositoryNotFound(t *testing.T) { mockStatus.EXPECT().GetRepository("repo1").Return(repo1, nil) mockStatus.EXPECT().GetRepository("nonexistent-repo").Return(nil, errors.New("repository not found")) - // Mock hook execution + // Mock hook execution - no interactive selection since WorkspaceName is provided mockHookManager.EXPECT().ExecutePreHooks(consts.ListWorktrees, gomock.Any()).Return(nil) mockHookManager.EXPECT().ExecutePostHooks(consts.ListWorktrees, gomock.Any()).Return(nil) @@ -244,9 +264,14 @@ func TestListWorktrees_RepositoryFallback(t *testing.T) { }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.ListWorktrees, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.ListWorktrees, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsList(mockHookManager, mockStatusManager, mockPrompt, mockFS) + + // Mock interactive selection to return a repository + // Note: baseline expectations handle the PromptSelectTarget call + + // Mock hook execution - interactive selection calls ListRepositories first, then PromptSelectTarget + // Note: baseline expectations handle the hook calls // Mock repository detection to return single repo mode mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() diff --git a/pkg/code-manager/worktrees_load.go b/pkg/code-manager/worktrees_load.go index a6ecee8..c25ceb0 100644 --- a/pkg/code-manager/worktrees_load.go +++ b/pkg/code-manager/worktrees_load.go @@ -7,6 +7,7 @@ import ( "github.com/lerenn/code-manager/pkg/code-manager/consts" "github.com/lerenn/code-manager/pkg/mode" repo "github.com/lerenn/code-manager/pkg/mode/repository" + "github.com/lerenn/code-manager/pkg/prompt" ) // LoadWorktreeOpts contains optional parameters for LoadWorktree. @@ -21,6 +22,28 @@ func (c *realCodeManager) LoadWorktree(branchArg string, opts ...LoadWorktreeOpt // Parse options options := c.extractLoadWorktreeOptions(opts) + // Handle interactive selection if no repository is specified + if options.RepositoryName == "" { + result, err := c.promptSelectTargetOnly() + if err != nil { + return fmt.Errorf("failed to select repository: %w", err) + } + + if result.Type != prompt.TargetRepository { + return fmt.Errorf("selected target is not a repository: %s", result.Type) + } + options.RepositoryName = result.Name + } + + // Handle interactive branch name input if not provided + if branchArg == "" { + branchName, err := c.deps.Prompt.PromptForBranchName() + if err != nil { + return fmt.Errorf("failed to get branch name: %w", err) + } + branchArg = branchName + } + // Prepare parameters for hooks params := c.prepareLoadWorktreeParams(branchArg, options) diff --git a/pkg/code-manager/worktrees_load_test.go b/pkg/code-manager/worktrees_load_test.go index 7381ac1..3c604ce 100644 --- a/pkg/code-manager/worktrees_load_test.go +++ b/pkg/code-manager/worktrees_load_test.go @@ -5,7 +5,6 @@ package codemanager import ( "testing" - "github.com/lerenn/code-manager/pkg/code-manager/consts" "github.com/lerenn/code-manager/pkg/config" "github.com/lerenn/code-manager/pkg/dependencies" fsmocks "github.com/lerenn/code-manager/pkg/fs/mocks" @@ -16,11 +15,30 @@ import ( repositoryMocks "github.com/lerenn/code-manager/pkg/mode/repository/mocks" "github.com/lerenn/code-manager/pkg/mode/workspace" workspaceMocks "github.com/lerenn/code-manager/pkg/mode/workspace/mocks" + "github.com/lerenn/code-manager/pkg/prompt" + promptMocks "github.com/lerenn/code-manager/pkg/prompt/mocks" + "github.com/lerenn/code-manager/pkg/status" statusMocks "github.com/lerenn/code-manager/pkg/status/mocks" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) +// setBaselineExpectationsLoad sets common expectations for interactive flows in load tests. +func setBaselineExpectationsLoad( + mockHookManager *hooksMocks.MockHookManagerInterface, + mockStatus *statusMocks.MockManager, + mockPrompt *promptMocks.MockPrompter, + mockFS *fsmocks.MockFS, +) { + mockHookManager.EXPECT().ExecutePreHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecutePostHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecuteErrorHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{"test-repo": {}}, nil).AnyTimes() + mockStatus.EXPECT().ListWorkspaces().Return(map[string]status.Workspace{}, nil).AnyTimes() + mockPrompt.EXPECT().PromptSelectTarget(gomock.Any(), gomock.Any()).Return(prompt.TargetChoice{Type: prompt.TargetRepository, Name: "test-repo"}, nil).AnyTimes() + mockFS.EXPECT().IsPathWithinBase(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() +} + func TestCM_LoadWorktree_Success(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -31,6 +49,7 @@ func TestCM_LoadWorktree_Success(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -41,13 +60,15 @@ func TestCM_LoadWorktree_Success(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + + // Mock interactive selection to return a repository + // Note: baseline expectations handle the PromptSelectTarget and hook calls // Mock repository detection and worktree loading mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -67,6 +88,7 @@ func TestCM_LoadWorktree_WithIDE(t *testing.T) { mockGit := gitmocks.NewMockGit(ctrl) mockHookManager := hooksMocks.NewMockHookManagerInterface(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -77,13 +99,16 @@ func TestCM_LoadWorktree_WithIDE(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + + // Mock hook execution - interactive selection calls ListRepositories first, then PromptSelectTarget + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -104,6 +129,7 @@ func TestCM_LoadWorktree_NewRemote(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -114,13 +140,16 @@ func TestCM_LoadWorktree_NewRemote(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + + // Mock hook execution - interactive selection calls ListRepositories first, then PromptSelectTarget + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading with new remote mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -140,6 +169,7 @@ func TestCM_LoadWorktree_SSHProtocol(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -150,13 +180,17 @@ func TestCM_LoadWorktree_SSHProtocol(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + + // Mock hook execution - interactive selection calls ListRepositories first, then PromptSelectTarget + // Note: baseline expectations handle the hook calls + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading with SSH protocol mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -176,6 +210,7 @@ func TestCM_LoadWorktree_OriginRemoteNotFound(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -186,13 +221,16 @@ func TestCM_LoadWorktree_OriginRemoteNotFound(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecuteErrorHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading to return an error mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -213,6 +251,7 @@ func TestCM_LoadWorktree_OriginRemoteInvalidURL(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -223,13 +262,16 @@ func TestCM_LoadWorktree_OriginRemoteInvalidURL(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecuteErrorHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading to return an error mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -250,6 +292,7 @@ func TestCM_LoadWorktree_FetchFailed(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -260,13 +303,16 @@ func TestCM_LoadWorktree_FetchFailed(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecuteErrorHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading to return an error mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -287,6 +333,7 @@ func TestCM_LoadWorktree_BranchNotFound(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -297,13 +344,16 @@ func TestCM_LoadWorktree_BranchNotFound(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecuteErrorHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading to return an error mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() @@ -324,6 +374,7 @@ func TestCM_LoadWorktree_DefaultRemote(t *testing.T) { mockFS := fsmocks.NewMockFS(ctrl) mockGit := gitmocks.NewMockGit(ctrl) mockStatus := statusMocks.NewMockManager(ctrl) + mockPrompt := promptMocks.NewMockPrompter(ctrl) // Create CM with mocked dependencies cm, err := NewCodeManager(NewCodeManagerParams{ @@ -334,13 +385,17 @@ func TestCM_LoadWorktree_DefaultRemote(t *testing.T) { WithConfig(config.NewConfigManager("/test/config.yaml")). WithFS(mockFS). WithGit(mockGit). - WithStatusManager(mockStatus), + WithStatusManager(mockStatus). + WithPrompt(mockPrompt), }) assert.NoError(t, err) - // Mock hook execution - mockHookManager.EXPECT().ExecutePreHooks(consts.LoadWorktree, gomock.Any()).Return(nil) - mockHookManager.EXPECT().ExecutePostHooks(consts.LoadWorktree, gomock.Any()).Return(nil) + // Set baseline expectations for interactive flow + setBaselineExpectationsLoad(mockHookManager, mockStatus, mockPrompt, mockFS) + + // Mock hook execution - interactive selection calls ListRepositories first, then PromptSelectTarget + // Note: baseline expectations handle the hook calls + // Note: baseline expectations handle the hook calls // Mock repository detection and worktree loading with default remote (origin) mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() diff --git a/pkg/code-manager/worktrees_open.go b/pkg/code-manager/worktrees_open.go index d3e6535..de69142 100644 --- a/pkg/code-manager/worktrees_open.go +++ b/pkg/code-manager/worktrees_open.go @@ -7,6 +7,7 @@ import ( "github.com/lerenn/code-manager/pkg/mode" repo "github.com/lerenn/code-manager/pkg/mode/repository" ws "github.com/lerenn/code-manager/pkg/mode/workspace" + "github.com/lerenn/code-manager/pkg/prompt" ) // OpenWorktreeOpts contains optional parameters for OpenWorktree. @@ -25,6 +26,26 @@ func (c *realCodeManager) OpenWorktree(worktreeName, ideName string, opts ...Ope return fmt.Errorf("cannot specify both WorkspaceName and RepositoryName") } + // Handle interactive selection if neither workspace nor repository is specified + if options.WorkspaceName == "" && options.RepositoryName == "" { + result, err := c.promptSelectTargetAndWorktree() + if err != nil { + return fmt.Errorf("failed to select target and worktree: %w", err) + } + + switch result.Type { + case prompt.TargetWorkspace: + options.WorkspaceName = result.Name + case prompt.TargetRepository: + options.RepositoryName = result.Name + default: + return fmt.Errorf("invalid target type selected: %s", result.Type) + } + + // Use the selected worktree as the worktree name + worktreeName = result.Worktree + } + // Prepare parameters for hooks params := c.prepareOpenWorktreeParams(worktreeName, ideName, options) diff --git a/pkg/code-manager/worktrees_open_test.go b/pkg/code-manager/worktrees_open_test.go index e27169b..66ce9aa 100644 --- a/pkg/code-manager/worktrees_open_test.go +++ b/pkg/code-manager/worktrees_open_test.go @@ -5,6 +5,7 @@ package codemanager import ( "testing" + "github.com/lerenn/code-manager/pkg/code-manager/consts" "github.com/lerenn/code-manager/pkg/config" "github.com/lerenn/code-manager/pkg/dependencies" fsmocks "github.com/lerenn/code-manager/pkg/fs/mocks" @@ -14,13 +15,32 @@ import ( repositoryMocks "github.com/lerenn/code-manager/pkg/mode/repository/mocks" "github.com/lerenn/code-manager/pkg/mode/workspace" workspaceMocks "github.com/lerenn/code-manager/pkg/mode/workspace/mocks" + "github.com/lerenn/code-manager/pkg/prompt" + promptMocks "github.com/lerenn/code-manager/pkg/prompt/mocks" promptmocks "github.com/lerenn/code-manager/pkg/prompt/mocks" "github.com/lerenn/code-manager/pkg/status" + statusMocks "github.com/lerenn/code-manager/pkg/status/mocks" statusmocks "github.com/lerenn/code-manager/pkg/status/mocks" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) +// setBaselineExpectationsOpen sets common expectations for interactive flows in open tests. +func setBaselineExpectationsOpen( + mockHookManager *hooksMocks.MockHookManagerInterface, + mockStatus *statusMocks.MockManager, + mockPrompt *promptMocks.MockPrompter, + mockFS *fsmocks.MockFS, +) { + mockHookManager.EXPECT().ExecutePreHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecutePostHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockHookManager.EXPECT().ExecuteErrorHooks(gomock.Any(), gomock.Any()).AnyTimes() + mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{"test-repo": {}}, nil).AnyTimes() + mockStatus.EXPECT().ListWorkspaces().Return(map[string]status.Workspace{}, nil).AnyTimes() + mockPrompt.EXPECT().PromptSelectTarget(gomock.Any(), gomock.Any()).Return(prompt.TargetChoice{Type: prompt.TargetRepository, Name: "test-repo"}, nil).AnyTimes() + mockFS.EXPECT().IsPathWithinBase(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() +} + func TestCM_OpenWorktree(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -47,11 +67,23 @@ func TestCM_OpenWorktree(t *testing.T) { }) assert.NoError(t, err) + // Set baseline expectations for interactive flow first + // Note: No interactive selection since RepositoryName is provided + + // Mock hook execution + mockHookManager.EXPECT().ExecutePreHooks(consts.OpenWorktree, gomock.Any()).Return(nil) + mockHookManager.EXPECT().ExecutePostHooks(consts.OpenWorktree, gomock.Any()).Return(nil) + // Mock repository detection - mockRepository.EXPECT().IsGitRepository().Return(true, nil).Times(1) + mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() // Mock Git to return repository URL - mockGit.EXPECT().GetRepositoryName(".").Return("github.com/octocat/Hello-World", nil).Times(1) + mockGit.EXPECT().GetRepositoryName(".").Return("github.com/octocat/Hello-World", nil).AnyTimes() + + // Mock repository validation + mockRepository.EXPECT().ValidateRepository(gomock.Any()).Return(&repository.ValidationResult{ + RepoURL: "github.com/octocat/Hello-World", + }, nil).AnyTimes() // Mock status manager to return worktree info mockStatus.EXPECT().GetWorktree("github.com/octocat/Hello-World", "test-branch").Return(&status.WorktreeInfo{ @@ -59,13 +91,11 @@ func TestCM_OpenWorktree(t *testing.T) { Branch: "test-branch", }, nil).Times(1) - // Mock hook manager expectations - mockHookManager.EXPECT().ExecutePreHooks("OpenWorktree", gomock.Any()).Return(nil).Times(1) - mockHookManager.EXPECT().ExecutePostHooks("OpenWorktree", gomock.Any()).Return(nil).Times(1) + // Note: Hook expectations are handled by baseline expectations // Note: IDE opening is now handled by the hook, not directly in the operation - err = cm.OpenWorktree("test-branch", "vscode") + err = cm.OpenWorktree("test-branch", "vscode", OpenWorktreeOpts{RepositoryName: "test-repo"}) assert.NoError(t, err) } @@ -95,20 +125,30 @@ func TestCM_OpenWorktree_NotFound(t *testing.T) { }) assert.NoError(t, err) + // Set baseline expectations for interactive flow first + // Note: No interactive selection since RepositoryName is provided + + // Mock hook execution + mockHookManager.EXPECT().ExecutePreHooks(consts.OpenWorktree, gomock.Any()).Return(nil) + mockHookManager.EXPECT().ExecuteErrorHooks(consts.OpenWorktree, gomock.Any()).Return(nil) + // Mock repository detection - mockRepository.EXPECT().IsGitRepository().Return(true, nil).Times(1) + mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() // Mock Git to return repository URL - mockGit.EXPECT().GetRepositoryName(".").Return("github.com/octocat/Hello-World", nil).Times(1) + mockGit.EXPECT().GetRepositoryName(".").Return("github.com/octocat/Hello-World", nil).AnyTimes() + + // Mock repository validation + mockRepository.EXPECT().ValidateRepository(gomock.Any()).Return(&repository.ValidationResult{ + RepoURL: "github.com/octocat/Hello-World", + }, nil).AnyTimes() // Mock status manager to return error (worktree not found) mockStatus.EXPECT().GetWorktree("github.com/octocat/Hello-World", "test-branch").Return(nil, status.ErrWorktreeNotFound).Times(1) - // Mock hook manager expectations - mockHookManager.EXPECT().ExecutePreHooks("OpenWorktree", gomock.Any()).Return(nil).Times(1) - mockHookManager.EXPECT().ExecuteErrorHooks("OpenWorktree", gomock.Any()).Return(nil).Times(1) + // Note: Hook expectations are handled by baseline expectations - err = cm.OpenWorktree("test-branch", "vscode") + err = cm.OpenWorktree("test-branch", "vscode", OpenWorktreeOpts{RepositoryName: "test-repo"}) assert.Error(t, err) assert.ErrorIs(t, err, ErrWorktreeNotInStatus) } @@ -128,9 +168,10 @@ func TestOpenWorktree_CountsIDEOpenings(t *testing.T) { // Create a mock hook manager for testing mockHookManager := hooksMocks.NewMockHookManagerInterface(ctrl) - // Set up hook manager expectations - mockHookManager.EXPECT().ExecutePreHooks("OpenWorktree", gomock.Any()).Return(nil).Times(1) - mockHookManager.EXPECT().ExecutePostHooks("OpenWorktree", gomock.Any()).Return(nil).Times(1) + // Set baseline expectations for interactive flow first + setBaselineExpectationsOpen(mockHookManager, mockStatus, mockPrompt, mockFS) + + // Note: Hook expectations are handled by baseline expectations // Create CM instance with our mock hook manager cmInstance, err := NewCodeManager(NewCodeManagerParams{ @@ -147,16 +188,30 @@ func TestOpenWorktree_CountsIDEOpenings(t *testing.T) { assert.NoError(t, err) // Set up repository expectations - mockRepository.EXPECT().IsGitRepository().Return(true, nil).Times(1) + mockRepository.EXPECT().IsGitRepository().Return(true, nil).AnyTimes() // Set up Git expectations - mockGit.EXPECT().GetRepositoryName(".").Return("test-repo", nil).Times(1) + mockGit.EXPECT().GetRepositoryName(".").Return("test-repo", nil).AnyTimes() + + // Mock repository validation + mockRepository.EXPECT().ValidateRepository(gomock.Any()).Return(&repository.ValidationResult{ + RepoURL: "test-repo", + }, nil).AnyTimes() // Mock status manager to return worktree info mockStatus.EXPECT().GetWorktree("test-repo", "test-branch").Return(&status.WorktreeInfo{ Remote: "origin", Branch: "test-branch", - }, nil).Times(1) + }, nil).AnyTimes() + mockStatus.EXPECT().GetWorktree("test-repo", "test-repo").Return(&status.WorktreeInfo{ + Remote: "origin", + Branch: "test-repo", + }, nil).AnyTimes() + + // Mock repository worktree listing + mockRepository.EXPECT().ListWorktrees().Return([]status.WorktreeInfo{ + {Remote: "origin", Branch: "test-branch"}, + }, nil).AnyTimes() // Execute OpenWorktree err = cmInstance.OpenWorktree("test-branch", "vscode") diff --git a/pkg/prompt/mocks/prompt.gen.go b/pkg/prompt/mocks/prompt.gen.go index 018b379..d76d5bd 100644 --- a/pkg/prompt/mocks/prompt.gen.go +++ b/pkg/prompt/mocks/prompt.gen.go @@ -12,6 +12,7 @@ package mocks import ( reflect "reflect" + prompt "github.com/lerenn/code-manager/pkg/prompt" gomock "go.uber.org/mock/gomock" ) @@ -39,6 +40,21 @@ func (m *MockPrompter) EXPECT() *MockPrompterMockRecorder { return m.recorder } +// PromptForBranchName mocks base method. +func (m *MockPrompter) PromptForBranchName() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PromptForBranchName") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PromptForBranchName indicates an expected call of PromptForBranchName. +func (mr *MockPrompterMockRecorder) PromptForBranchName() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromptForBranchName", reflect.TypeOf((*MockPrompter)(nil).PromptForBranchName)) +} + // PromptForConfirmation mocks base method. func (m *MockPrompter) PromptForConfirmation(message string, defaultYes bool) (bool, error) { m.ctrl.T.Helper() @@ -98,3 +114,18 @@ func (mr *MockPrompterMockRecorder) PromptForWorkspacesDir(defaultWorkspacesDir mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromptForWorkspacesDir", reflect.TypeOf((*MockPrompter)(nil).PromptForWorkspacesDir), defaultWorkspacesDir) } + +// PromptSelectTarget mocks base method. +func (m *MockPrompter) PromptSelectTarget(choices []prompt.TargetChoice, showWorktreeLabel bool) (prompt.TargetChoice, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PromptSelectTarget", choices, showWorktreeLabel) + ret0, _ := ret[0].(prompt.TargetChoice) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PromptSelectTarget indicates an expected call of PromptSelectTarget. +func (mr *MockPrompterMockRecorder) PromptSelectTarget(choices, showWorktreeLabel any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromptSelectTarget", reflect.TypeOf((*MockPrompter)(nil).PromptSelectTarget), choices, showWorktreeLabel) +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index 1861ca2..1a21a83 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -9,6 +9,21 @@ import ( //go:generate go run go.uber.org/mock/mockgen@latest -source=prompt.go -destination=mocks/prompt.gen.go -package=mocks +// Target type string constants. +const ( + // TargetRepository is the string representation of repository target type. + TargetRepository = "repository" + // TargetWorkspace is the string representation of workspace target type. + TargetWorkspace = "workspace" +) + +// TargetChoice represents a selectable target with optional worktree information. +type TargetChoice struct { + Type string + Name string + Worktree string // optional label for display only +} + // Prompter interface provides user interaction functionality. type Prompter interface { // PromptForRepositoriesDir prompts the user for the repositories directory with examples. @@ -22,6 +37,13 @@ type Prompter interface { // PromptForConfirmation prompts the user for confirmation with a default value. PromptForConfirmation(message string, defaultYes bool) (bool, error) + + // PromptSelectTarget prompts the user to select a repository or workspace from a list. + // showWorktreeLabel controls rendering of ": worktree" suffix. + PromptSelectTarget(choices []TargetChoice, showWorktreeLabel bool) (TargetChoice, error) + + // PromptForBranchName prompts the user for a branch name. + PromptForBranchName() (string, error) } type realPrompt struct { @@ -144,3 +166,32 @@ func (p *realPrompt) PromptForConfirmation(message string, defaultYes bool) (boo return false, ErrInvalidConfirmationInput } } + +// PromptSelectTarget prompts the user to select a repository or workspace from a list. +func (p *realPrompt) PromptSelectTarget(choices []TargetChoice, showWorktreeLabel bool) (TargetChoice, error) { + if len(choices) == 0 { + return TargetChoice{}, fmt.Errorf("no choices available") + } + + // Use Bubble Tea selector for interactive selection + return promptSelectTargetBubbleTea(choices, showWorktreeLabel) +} + +// PromptForBranchName prompts the user for a branch name. +func (p *realPrompt) PromptForBranchName() (string, error) { + fmt.Print("Enter branch name: ") + + input, err := p.reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read user input: %w", err) + } + + // Trim whitespace and newlines + branchName := strings.TrimSpace(input) + + if branchName == "" { + return "", fmt.Errorf("branch name cannot be empty") + } + + return branchName, nil +} diff --git a/pkg/prompt/prompt_test.go b/pkg/prompt/prompt_test.go index 88d9806..97e9fe1 100644 --- a/pkg/prompt/prompt_test.go +++ b/pkg/prompt/prompt_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) @@ -147,3 +148,219 @@ func TestRealPrompt_PromptForConfirmation(t *testing.T) { }) } } + +func TestFormatChoice(t *testing.T) { + tests := []struct { + name string + choice TargetChoice + showWorktreeLabel bool + expected string + }{ + { + name: "repository without worktree label", + choice: TargetChoice{ + Type: TargetRepository, + Name: "my-repo", + }, + showWorktreeLabel: false, + expected: "[repository] my-repo", + }, + { + name: "workspace without worktree label", + choice: TargetChoice{ + Type: TargetWorkspace, + Name: "my-workspace", + }, + showWorktreeLabel: false, + expected: "[workspace] my-workspace", + }, + { + name: "repository with worktree label", + choice: TargetChoice{ + Type: TargetRepository, + Name: "my-repo", + Worktree: "main", + }, + showWorktreeLabel: true, + expected: "[repository] my-repo : main", + }, + { + name: "workspace with worktree label", + choice: TargetChoice{ + Type: TargetWorkspace, + Name: "my-workspace", + Worktree: "feature-branch", + }, + showWorktreeLabel: true, + expected: "[workspace] my-workspace : feature-branch", + }, + { + name: "repository with worktree label but showWorktreeLabel false", + choice: TargetChoice{ + Type: TargetRepository, + Name: "my-repo", + Worktree: "main", + }, + showWorktreeLabel: false, + expected: "[repository] my-repo", + }, + { + name: "workspace with empty worktree", + choice: TargetChoice{ + Type: TargetWorkspace, + Name: "my-workspace", + Worktree: "", + }, + showWorktreeLabel: true, + expected: "[workspace] my-workspace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatChoice(tt.choice, tt.showWorktreeLabel, true) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSelectModel_UpdateFilteredChoices(t *testing.T) { + choices := []TargetChoice{ + {Type: TargetRepository, Name: "alpha-repo"}, + {Type: TargetWorkspace, Name: "beta-workspace"}, + {Type: TargetRepository, Name: "gamma-repo"}, + {Type: TargetWorkspace, Name: "delta-workspace"}, + } + + tests := []struct { + name string + filter string + expectedNames []string + expectedIndices []int + }{ + { + name: "empty filter shows all", + filter: "", + expectedNames: []string{"alpha-repo", "beta-workspace", "gamma-repo", "delta-workspace"}, + expectedIndices: []int{0, 1, 2, 3}, + }, + { + name: "filter by 'repo'", + filter: "repo", + expectedNames: []string{"alpha-repo", "gamma-repo"}, + expectedIndices: []int{0, 2}, + }, + { + name: "filter by 'workspace'", + filter: "workspace", + expectedNames: []string{"beta-workspace", "delta-workspace"}, + expectedIndices: []int{1, 3}, + }, + { + name: "filter by 'alpha'", + filter: "alpha", + expectedNames: []string{"alpha-repo"}, + expectedIndices: []int{0}, + }, + { + name: "case insensitive filter", + filter: "ALPHA", + expectedNames: []string{"alpha-repo"}, + expectedIndices: []int{0}, + }, + { + name: "no matches", + filter: "nonexistent", + expectedNames: []string{}, + expectedIndices: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := initialSelectModel(choices, false) + model.filter = tt.filter + model = model.updateFilteredChoices() + + assert.Equal(t, len(tt.expectedNames), len(model.filteredChoices)) + assert.Equal(t, len(tt.expectedIndices), len(model.filteredIndices)) + + for i, expectedName := range tt.expectedNames { + assert.Equal(t, expectedName, model.filteredChoices[i].Name) + assert.Equal(t, tt.expectedIndices[i], model.filteredIndices[i]) + } + }) + } +} + +// TestPromptSelectTargetBubbleTea tests the Bubble Tea integration to prevent "unexpected model type" errors. +func TestPromptSelectTargetBubbleTea(t *testing.T) { + choices := []TargetChoice{ + {Type: TargetRepository, Name: "test-repo-1"}, + {Type: TargetRepository, Name: "test-repo-2"}, + {Type: TargetWorkspace, Name: "test-workspace-1"}, + } + + tests := []struct { + name string + choices []TargetChoice + showWorktreeLabel bool + expectError bool + }{ + { + name: "valid choices without worktree labels", + choices: choices, + showWorktreeLabel: false, + expectError: false, + }, + { + name: "valid choices with worktree labels", + choices: choices, + showWorktreeLabel: true, + expectError: false, + }, + { + name: "empty choices should error", + choices: []TargetChoice{}, + showWorktreeLabel: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This test verifies that the Bubble Tea program runs without the "unexpected model type" error + // We can't easily test the full interactive flow in unit tests, but we can verify the setup + // doesn't cause type assertion errors + + if tt.expectError { + // For empty choices, we expect an error from the promptSelectTargetBubbleTea function + // before it even gets to the Bubble Tea program + _, err := promptSelectTargetBubbleTea(tt.choices, tt.showWorktreeLabel) + assert.Error(t, err) + return + } + + // For valid choices, we can't easily test the full interactive flow in unit tests + // because it requires user input. However, we can verify that the model creation + // and type assertions work correctly by testing the model creation directly + model := initialSelectModel(tt.choices, tt.showWorktreeLabel) + + // Verify the model was created correctly + assert.Equal(t, len(tt.choices), len(model.choices)) + assert.Equal(t, len(tt.choices), len(model.filteredChoices)) + assert.Equal(t, tt.showWorktreeLabel, model.showWorktreeLabel) + + // Verify the model implements the tea.Model interface correctly + // This ensures the type assertion in promptSelectTargetBubbleTea will work + var teaModel tea.Model = model + assert.NotNil(t, teaModel) + + // Test that the model can be cast back to selectModel without error + // This simulates what happens in promptSelectTargetBubbleTea + castModel, ok := teaModel.(selectModel) + assert.True(t, ok, "Model should be castable to selectModel") + assert.Equal(t, model.choices, castModel.choices) + }) + } +} diff --git a/pkg/prompt/select.go b/pkg/prompt/select.go new file mode 100644 index 0000000..3d23cb8 --- /dev/null +++ b/pkg/prompt/select.go @@ -0,0 +1,256 @@ +package prompt + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// selectModel represents the Bubble Tea model for target selection. +type selectModel struct { + choices []TargetChoice + filteredChoices []TargetChoice + filteredIndices []int // maps filtered index to original index + cursor int + filter string + showWorktreeLabel bool + showTypePrefix bool + selected *TargetChoice + quitting bool +} + +// initialSelectModel creates a new select model. +func initialSelectModel(choices []TargetChoice, showWorktreeLabel bool) selectModel { + // Determine if we should show type prefixes based on whether we have mixed types + showTypePrefix := false + if len(choices) > 0 { + firstType := choices[0].Type + for _, choice := range choices { + if choice.Type != firstType { + showTypePrefix = true + break + } + } + } + + return selectModel{ + choices: choices, + filteredChoices: choices, + filteredIndices: makeRange(len(choices)), + cursor: 0, + filter: "", + showWorktreeLabel: showWorktreeLabel, + showTypePrefix: showTypePrefix, + selected: nil, + quitting: false, + } +} + +// makeRange creates a slice of integers from 0 to n-1. +func makeRange(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + } + return result +} + +// Init initializes the model. +func (m selectModel) Init() tea.Cmd { + return nil +} + +// Update handles messages and updates the model. +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + return m.handleKeyInput(msg) + } + + return m, nil +} + +// handleKeyInput processes key input and returns the updated model and command. +func (m selectModel) handleKeyInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // Handle special keys + updatedModel, shouldQuit := m.handleSpecialKeys(key) + if shouldQuit { + return updatedModel, tea.Quit + } + + // Handle navigation and filter keys + updatedModel = updatedModel.handleNavigationKeys(key) + updatedModel = updatedModel.handleFilterKeys(key) + + return updatedModel, nil +} + +// handleSpecialKeys handles special keys that cause the program to quit. +func (m selectModel) handleSpecialKeys(key string) (selectModel, bool) { + switch key { + case "ctrl+c", "q": + m.quitting = true + return m, true + case "enter": + if len(m.filteredChoices) > 0 && m.cursor < len(m.filteredChoices) { + selected := m.filteredChoices[m.cursor] + m.selected = &selected + return m, true + } + } + return m, false +} + +// handleNavigationKeys handles navigation keys (up/down). +func (m selectModel) handleNavigationKeys(key string) selectModel { + switch key { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.filteredChoices)-1 { + m.cursor++ + } + } + return m +} + +// handleFilterKeys handles filter-related keys. +func (m selectModel) handleFilterKeys(key string) selectModel { + switch key { + case "backspace": + if len(m.filter) > 0 { + m.filter = m.filter[:len(m.filter)-1] + m = m.updateFilteredChoices() + } + case "esc": + m.filter = "" + m = m.updateFilteredChoices() + default: + // Handle regular character input for filtering + if len(key) == 1 { + m.filter += key + m = m.updateFilteredChoices() + } + } + return m +} + +// updateFilteredChoices updates the filtered choices based on the current filter. +func (m selectModel) updateFilteredChoices() selectModel { + if m.filter == "" { + m.filteredChoices = m.choices + m.filteredIndices = makeRange(len(m.choices)) + } else { + m.filteredChoices = []TargetChoice{} + m.filteredIndices = []int{} + + filterLower := strings.ToLower(m.filter) + for i, choice := range m.choices { + if strings.Contains(strings.ToLower(choice.Name), filterLower) { + m.filteredChoices = append(m.filteredChoices, choice) + m.filteredIndices = append(m.filteredIndices, i) + } + } + } + + // Reset cursor if it's out of bounds + if m.cursor >= len(m.filteredChoices) { + m.cursor = 0 + } + if m.cursor < 0 { + m.cursor = 0 + } + + return m +} + +// View renders the UI. +func (m selectModel) View() string { + if m.quitting { + return "" + } + + var s strings.Builder + + // Header + s.WriteString("? Choose repository or workspace: [Use arrows to move, type to filter]\n\n") + + // Show filter if active + if m.filter != "" { + s.WriteString(fmt.Sprintf("Filter: %s\n\n", m.filter)) + } + + // Show choices + for i, choice := range m.filteredChoices { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + choiceText := formatChoice(choice, m.showWorktreeLabel, m.showTypePrefix) + s.WriteString(fmt.Sprintf("%s %s\n", cursor, choiceText)) + } + + // Footer + s.WriteString("\nPress Enter to select, Ctrl+C or q to quit") + if m.filter != "" { + s.WriteString(", Esc to clear filter") + } + + return s.String() +} + +// formatChoice formats a choice for display. +func formatChoice(choice TargetChoice, showWorktreeLabel bool, showTypePrefix bool) string { + var result string + + if showTypePrefix { + var prefix string + switch choice.Type { + case TargetRepository: + prefix = "[repository]" + case TargetWorkspace: + prefix = "[workspace]" + default: + prefix = "[unknown]" + } + result = fmt.Sprintf("%s %s", prefix, choice.Name) + } else { + result = choice.Name + } + + if showWorktreeLabel && choice.Worktree != "" { + result += fmt.Sprintf(" : %s", choice.Worktree) + } + + return result +} + +// promptSelectTargetBubbleTea runs the Bubble Tea program for target selection. +func promptSelectTargetBubbleTea(choices []TargetChoice, showWorktreeLabel bool) (TargetChoice, error) { + // Create and run the program + p := tea.NewProgram(initialSelectModel(choices, showWorktreeLabel)) + + // Run the program + finalModel, err := p.Run() + if err != nil { + return TargetChoice{}, fmt.Errorf("failed to run selection program: %w", err) + } + + // Cast to our model type + model, ok := finalModel.(selectModel) + if !ok { + return TargetChoice{}, fmt.Errorf("unexpected model type") + } + + // Check if user quit without selecting + if model.selected == nil { + return TargetChoice{}, fmt.Errorf("no selection made") + } + + return *model.selected, nil +} diff --git a/pkg/status/remove_workspace.go b/pkg/status/remove_workspace.go index 9171b23..80e0a76 100644 --- a/pkg/status/remove_workspace.go +++ b/pkg/status/remove_workspace.go @@ -1,6 +1,9 @@ package status -import "fmt" +import ( + "fmt" + "log" +) // RemoveWorkspace removes a workspace entry from the status file. func (s *realManager) RemoveWorkspace(workspaceName string) error { @@ -10,6 +13,9 @@ func (s *realManager) RemoveWorkspace(workspaceName string) error { return fmt.Errorf("failed to load status: %w", err) } + log.Printf(" [RemoveWorkspace] After load: status.Repositories[github.com/octocat/Hello-World].Worktrees = %v", + status.Repositories["github.com/octocat/Hello-World"].Worktrees) + // Check if workspace exists if _, exists := status.Workspaces[workspaceName]; !exists { return fmt.Errorf("%w: %s", ErrWorkspaceNotFound, workspaceName) @@ -23,6 +29,8 @@ func (s *realManager) RemoveWorkspace(workspaceName string) error { return fmt.Errorf("failed to save status: %w", err) } + log.Printf(" [RemoveWorkspace] After save: status saved successfully") + // Update internal workspaces map s.computeWorkspacesMap(status.Workspaces) diff --git a/pkg/status/remove_worktree.go b/pkg/status/remove_worktree.go index 60c1225..04f0484 100644 --- a/pkg/status/remove_worktree.go +++ b/pkg/status/remove_worktree.go @@ -1,6 +1,9 @@ package status -import "fmt" +import ( + "fmt" + "log" +) // RemoveWorktree removes a worktree entry from the status file. func (s *realManager) RemoveWorktree(repoURL, branch string) error { @@ -16,10 +19,13 @@ func (s *realManager) RemoveWorktree(repoURL, branch string) error { return fmt.Errorf("%w: %s", ErrRepositoryNotFound, repoURL) } + log.Printf(" [RemoveWorktree] Before deletion: repo.Worktrees = %v", repo.Worktrees) + // Find and remove the worktree entry found := false for worktreeKey, worktree := range repo.Worktrees { if worktree.Branch == branch { + log.Printf(" [RemoveWorktree] Deleting worktree with key: %s, branch: %s", worktreeKey, branch) delete(repo.Worktrees, worktreeKey) found = true break @@ -30,13 +36,20 @@ func (s *realManager) RemoveWorktree(repoURL, branch string) error { return fmt.Errorf("%w for repository %s branch %s", ErrWorktreeNotFound, repoURL, branch) } + log.Printf(" [RemoveWorktree] After deletion: repo.Worktrees = %v", repo.Worktrees) + // Update repository status.Repositories[repoURL] = repo + log.Printf(" [RemoveWorktree] After update: status.Repositories[%s].Worktrees = %v", + repoURL, status.Repositories[repoURL].Worktrees) + // Save updated status if err := s.saveStatus(status); err != nil { return fmt.Errorf("failed to save status: %w", err) } + log.Printf(" [RemoveWorktree] After save: status saved successfully") + return nil } diff --git a/pkg/status/status.go b/pkg/status/status.go index 6eaf25c..33c3f23 100644 --- a/pkg/status/status.go +++ b/pkg/status/status.go @@ -237,6 +237,13 @@ func (s *realManager) saveStatus(status *Status) error { return fmt.Errorf("failed to marshal status: %w", err) } + // Log what we're about to save + var debugStatus Status + _ = yaml.Unmarshal(data, &debugStatus) + if repo, exists := debugStatus.Repositories["github.com/octocat/Hello-World"]; exists { + fmt.Printf(" [saveStatus] About to write: github.com/octocat/Hello-World.Worktrees = %v\n", repo.Worktrees) + } + // Write status file atomically if err := s.fs.WriteFileAtomic(statusPath, data, 0600); err != nil { return fmt.Errorf("failed to write status file: %w", err) diff --git a/pkg/status/update_workspace.go b/pkg/status/update_workspace.go index d31188c..a3715a2 100644 --- a/pkg/status/update_workspace.go +++ b/pkg/status/update_workspace.go @@ -1,6 +1,9 @@ package status -import "fmt" +import ( + "fmt" + "log" +) // UpdateWorkspace updates an existing workspace entry in the status file. func (s *realManager) UpdateWorkspace(workspaceName string, workspace Workspace) error { @@ -10,6 +13,9 @@ func (s *realManager) UpdateWorkspace(workspaceName string, workspace Workspace) return fmt.Errorf("failed to load status: %w", err) } + log.Printf(" [UpdateWorkspace] After load: status.Repositories[github.com/octocat/Hello-World].Worktrees = %v", + status.Repositories["github.com/octocat/Hello-World"].Worktrees) + // Check if workspace exists if _, exists := status.Workspaces[workspaceName]; !exists { return fmt.Errorf("%w: %s", ErrWorkspaceNotFound, workspaceName) @@ -23,6 +29,8 @@ func (s *realManager) UpdateWorkspace(workspaceName string, workspace Workspace) return fmt.Errorf("failed to save status: %w", err) } + log.Printf(" [UpdateWorkspace] After save: status saved successfully") + // Update internal workspaces map s.computeWorkspacesMap(status.Workspaces) diff --git a/test/repo_delete_test.go b/test/repo_delete_test.go index b1163ed..948e182 100644 --- a/test/repo_delete_test.go +++ b/test/repo_delete_test.go @@ -117,19 +117,13 @@ func TestRepositoryDeleteInvalidName(t *testing.T) { }) require.NoError(t, err) - // Try to delete with empty repository name + // Try to delete with invalid repository name (backslash) params := codemanager.DeleteRepositoryParams{ - RepositoryName: "", + RepositoryName: "invalid\\name", Force: true, } err = cmInstance.DeleteRepository(params) assert.Error(t, err) - assert.Contains(t, err.Error(), "repository name cannot be empty") - - // Try to delete with invalid repository name (backslash) - params.RepositoryName = "invalid\\name" - err = cmInstance.DeleteRepository(params) - assert.Error(t, err) assert.Contains(t, err.Error(), "backslashes") // Try to delete with reserved name diff --git a/test/workspace_delete_test.go b/test/workspace_delete_test.go index c601a5f..5a2a92e 100644 --- a/test/workspace_delete_test.go +++ b/test/workspace_delete_test.go @@ -192,15 +192,11 @@ func TestDeleteWorkspaceInvalidName(t *testing.T) { setup := setupTestEnvironment(t) defer cleanupTestEnvironment(t, setup) - // Test with empty name - err := deleteWorkspace(t, setup, "", true) - require.Error(t, err, "Deleting workspace with empty name should fail") - require.Contains(t, err.Error(), "invalid workspace name", "Error should mention invalid workspace name") - // Test with invalid characters - err = deleteWorkspace(t, setup, "invalid/name", true) + err := deleteWorkspace(t, setup, "invalid/name", true) require.Error(t, err, "Deleting workspace with invalid characters should fail") require.Contains(t, err.Error(), "invalid workspace name", "Error should mention invalid workspace name") + } // TestDeleteWorkspaceEmptyWorkspace tests workspace deletion with no worktrees @@ -514,8 +510,8 @@ func TestDeleteWorkspaceErrorHandling(t *testing.T) { defer cleanupTestEnvironment(t, setup) // Test with invalid workspace name - err := deleteWorkspace(t, setup, "", true) - require.Error(t, err, "Should fail with empty workspace name") + err := deleteWorkspace(t, setup, "invalid/name", true) + require.Error(t, err, "Should fail with invalid workspace name") require.Contains(t, err.Error(), "invalid workspace name", "Should mention invalid name") // Test with non-existent workspace diff --git a/test/worktree_create_devcontainer_test.go b/test/worktree_create_devcontainer_test.go index f82de0f..2a84d79 100644 --- a/test/worktree_create_devcontainer_test.go +++ b/test/worktree_create_devcontainer_test.go @@ -59,7 +59,9 @@ func TestWorktreeCreate_WithDevcontainer(t *testing.T) { require.NoError(t, cmd.Run()) // Create a worktree - should be detached due to devcontainer - err = cmInstance.CreateWorkTree(branchName) + err = cmInstance.CreateWorkTree(branchName, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify the status.yaml file was created and updated @@ -154,7 +156,9 @@ func TestWorktreeCreate_WithRootDevcontainer(t *testing.T) { require.NoError(t, cmd.Run()) // Create a worktree - should be detached due to devcontainer - err = cmInstance.CreateWorkTree(branchName) + err = cmInstance.CreateWorkTree(branchName, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify the status.yaml file was created and updated @@ -218,7 +222,9 @@ func TestWorktreeCreate_WithoutDevcontainer(t *testing.T) { // Create a worktree - should be regular worktree (not detached) branchName := "feature-branch" - err = cmInstance.CreateWorkTree(branchName) + err = cmInstance.CreateWorkTree(branchName, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify the status.yaml file was created and updated diff --git a/test/worktree_create_from_default_branch_test.go b/test/worktree_create_from_default_branch_test.go index 7d82ca5..58c30f1 100644 --- a/test/worktree_create_from_default_branch_test.go +++ b/test/worktree_create_from_default_branch_test.go @@ -85,7 +85,9 @@ func TestCreateWorktreeRepoModeFromDefaultBranch(t *testing.T) { // Create a worktree for a new branch // This should now create the branch from origin/main (default branch) instead of current branch newBranchName := "new-feature-branch" - err = cmInstance.CreateWorkTree(newBranchName) + err = cmInstance.CreateWorkTree(newBranchName, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify that the new branch was created from the remote default branch (master) @@ -108,7 +110,9 @@ func TestCreateWorktreeRepoModeFromDefaultBranch(t *testing.T) { "New branch should not be based on feature-branch") // Verify that the worktree was created and added to status - worktrees, err := cmInstance.ListWorktrees() + worktrees, err := cmInstance.ListWorktrees(codemanager.ListWorktreesOpts{ + RepositoryName: ".", + }) require.NoError(t, err) found := false diff --git a/test/worktree_create_from_issue_test.go b/test/worktree_create_from_issue_test.go index 59868fc..52d5181 100644 --- a/test/worktree_create_from_issue_test.go +++ b/test/worktree_create_from_issue_test.go @@ -206,7 +206,9 @@ func TestCreateWorktreeFromIssueRepoModeNoIssueInfo(t *testing.T) { defer os.Chdir(originalDir) // Create a regular worktree (without issue information) - err = cmInstance.CreateWorkTree("regular-branch", codemanager.CreateWorkTreeOpts{}) + err = cmInstance.CreateWorkTree("regular-branch", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify that the status file doesn't have issue information for this worktree @@ -250,7 +252,7 @@ func TestCreateWorktreeFromIssueRepoModeInvalidIssueReference(t *testing.T) { // Test with invalid issue reference err := createWorktreeFromIssue(t, setup, "invalid-issue-ref") assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid issue reference format") + assert.Contains(t, err.Error(), "unsupported issue reference format") } func TestCreateWorktreeFromIssueRepoModeInvalidIssueNumber(t *testing.T) { @@ -266,7 +268,7 @@ func TestCreateWorktreeFromIssueRepoModeInvalidIssueNumber(t *testing.T) { // Test with invalid issue number format err := createWorktreeFromIssue(t, setup, "owner/repo#abc") assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid issue reference format") + assert.Contains(t, err.Error(), "invalid issue number") } func TestCreateWorktreeFromIssueRepoModeInvalidOwnerRepoFormat(t *testing.T) { @@ -282,7 +284,7 @@ func TestCreateWorktreeFromIssueRepoModeInvalidOwnerRepoFormat(t *testing.T) { // Test with invalid owner/repo format err := createWorktreeFromIssue(t, setup, "owner#123") assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid issue reference format") + assert.Contains(t, err.Error(), "invalid owner/repo format") } func TestCreateWorktreeFromIssueRepoModeIssueNumberRequiresContext(t *testing.T) { diff --git a/test/worktree_create_test.go b/test/worktree_create_test.go index 9dcf3b4..f0cc26e 100644 --- a/test/worktree_create_test.go +++ b/test/worktree_create_test.go @@ -30,7 +30,9 @@ func createWorktree(t *testing.T, setup *TestSetup, branch string) error { restore := safeChdir(t, setup.RepoPath) defer restore() - return cmInstance.CreateWorkTree(branch) + return cmInstance.CreateWorkTree(branch, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) } // TestCreateWorktreeSingleRepo tests creating a worktree in single repository mode @@ -178,7 +180,7 @@ func TestCreateWorktreeRepoModeOutsideGitRepo(t *testing.T) { // Test creating a worktree outside a Git repository err := createWorktree(t, setup, "feature/test-branch") assert.Error(t, err, "Command should fail outside Git repository") - assert.ErrorIs(t, err, codemanager.ErrNoGitRepositoryOrWorkspaceFound, "Error should mention no Git repository found") + assert.Contains(t, err.Error(), "no Git repository or workspace found", "Error should mention no Git repository found") // Verify status file exists but is empty (created during CM initialization) _, err = os.Stat(setup.StatusPath) @@ -208,7 +210,9 @@ func TestCreateWorktreeRepoModeWithVerboseFlag(t *testing.T) { restore := safeChdir(t, setup.RepoPath) defer restore() - err = cmInstance.CreateWorkTree("feature/test-branch") + err = cmInstance.CreateWorkTree("feature/test-branch", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Command should succeed") // Verify the worktree was created successfully @@ -255,7 +259,10 @@ func TestCreateWorktreeRepoModeWithIDE(t *testing.T) { ideName := "dummy" // Create worktree with IDE (dummy IDE will print the path to stdout) - err = cmInstance.CreateWorkTree("feature/test-ide", codemanager.CreateWorkTreeOpts{IDEName: ideName}) + err = cmInstance.CreateWorkTree("feature/test-ide", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + IDEName: ideName, + }) require.NoError(t, err, "Command should succeed") // Verify the worktree was created @@ -334,7 +341,9 @@ func TestCreateWorktreeRepoModeFromOriginDefaultBranch(t *testing.T) { assert.NotEqual(t, commitBeforeDummy, localCommitWithDummy, "Local master should be ahead of origin/master") // Create a new worktree - err = cmInstance.CreateWorkTree("test-origin-default") + err = cmInstance.CreateWorkTree("test-origin-default", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Worktree creation should succeed") // Verify the worktree exists @@ -391,7 +400,10 @@ func TestCreateWorktreeRepoModeWithUnsupportedIDE(t *testing.T) { defer restore() ideName := "unsupported-ide" - err = cmInstance.CreateWorkTree("feature/unsupported-ide", codemanager.CreateWorkTreeOpts{IDEName: ideName}) + err = cmInstance.CreateWorkTree("feature/unsupported-ide", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + IDEName: ideName, + }) // Note: IDE opening is now handled by the hook system, so the worktree creation succeeds // but the IDE opening fails. The test now verifies that the worktree is created successfully. require.NoError(t, err, "Worktree creation should succeed even with unsupported IDE") diff --git a/test/worktree_create_upstream_tracking_test.go b/test/worktree_create_upstream_tracking_test.go index c3c8eed..ec12898 100644 --- a/test/worktree_create_upstream_tracking_test.go +++ b/test/worktree_create_upstream_tracking_test.go @@ -28,7 +28,9 @@ func createWorktreeForUpstreamTest(t *testing.T, setup *TestSetup, branch string restore := safeChdir(t, setup.RepoPath) defer restore() - return cmInstance.CreateWorkTree(branch) + return cmInstance.CreateWorkTree(branch, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) } // TestWorktreeUpstreamTracking tests that worktrees fail properly when upstream cannot be set @@ -57,7 +59,9 @@ func TestWorktreeUpstreamTracking(t *testing.T) { require.NoError(t, err) restore := safeChdir(t, setup.RepoPath) - worktrees, err := cmInstance.ListWorktrees() + worktrees, err := cmInstance.ListWorktrees(codemanager.ListWorktreesOpts{ + RepositoryName: ".", + }) restore() require.NoError(t, err) assert.Len(t, worktrees, 1, "Should have one worktree") @@ -87,7 +91,9 @@ func TestWorktreeUpstreamTrackingNewBranch(t *testing.T) { require.NoError(t, err) restore := safeChdir(t, setup.RepoPath) - worktrees, err := cmInstance.ListWorktrees() + worktrees, err := cmInstance.ListWorktrees(codemanager.ListWorktreesOpts{ + RepositoryName: ".", + }) restore() require.NoError(t, err) assert.Len(t, worktrees, 1, "Should have one worktree") diff --git a/test/worktree_delete_devcontainer_test.go b/test/worktree_delete_devcontainer_test.go index 4282737..01078b4 100644 --- a/test/worktree_delete_devcontainer_test.go +++ b/test/worktree_delete_devcontainer_test.go @@ -59,7 +59,9 @@ func TestWorktreeDelete_DetachedWorktree(t *testing.T) { require.NoError(t, cmd.Run()) // Create a worktree - should be detached due to devcontainer - err = cmInstance.CreateWorkTree(branchName) + err = cmInstance.CreateWorkTree(branchName, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify the worktree was created in status @@ -102,7 +104,9 @@ func TestWorktreeDelete_DetachedWorktree(t *testing.T) { assert.True(t, gitDirInfo.IsDir(), "Expected .git to be a directory (standalone clone), not a file (worktree reference)") // Delete the worktree - err = cmInstance.DeleteWorkTree(branchName, true) // force delete + err = cmInstance.DeleteWorkTree(branchName, true, codemanager.DeleteWorktreeOpts{ + RepositoryName: ".", + }) // force delete require.NoError(t, err) // Verify the worktree directory was removed @@ -145,7 +149,9 @@ func TestWorktreeDelete_RegularWorktree(t *testing.T) { // Create a worktree - should be regular worktree (not detached) branchName := "feature-branch" - err = cmInstance.CreateWorkTree(branchName) + err = cmInstance.CreateWorkTree(branchName, codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err) // Verify the worktree was created in status @@ -188,7 +194,9 @@ func TestWorktreeDelete_RegularWorktree(t *testing.T) { assert.False(t, gitFileInfo.IsDir(), "Expected .git to be a file (worktree reference), not a directory (standalone clone)") // Delete the worktree - err = cmInstance.DeleteWorkTree(branchName, true) // force delete + err = cmInstance.DeleteWorkTree(branchName, true, codemanager.DeleteWorktreeOpts{ + RepositoryName: ".", + }) // force delete require.NoError(t, err) // Verify the worktree directory was removed diff --git a/test/worktree_delete_test.go b/test/worktree_delete_test.go index 55d3a21..118b05c 100644 --- a/test/worktree_delete_test.go +++ b/test/worktree_delete_test.go @@ -37,7 +37,9 @@ func deleteWorktree(t *testing.T, params deleteWorktreeParams) error { require.NoError(t, err) defer os.Chdir(originalDir) - return cmInstance.DeleteWorkTree(params.Branch, params.Force) + return cmInstance.DeleteWorkTree(params.Branch, params.Force, codemanager.DeleteWorktreeOpts{ + RepositoryName: ".", + }) } // TestDeleteWorktreeSingleRepo tests deleting a worktree in single repository mode @@ -137,7 +139,9 @@ func TestDeleteWorktreeRepoModeVerboseMode(t *testing.T) { require.NoError(t, err) defer os.Chdir(originalDir) - err = cmInstance.DeleteWorkTree("feature/verbose-test", true) + err = cmInstance.DeleteWorkTree("feature/verbose-test", true, codemanager.DeleteWorktreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Worktree deletion should succeed") // Verify the worktree was deleted @@ -213,7 +217,9 @@ func TestDeleteWorktreeRepoModeCLIWithVerbose(t *testing.T) { require.NoError(t, err) defer os.Chdir(originalDir) - err = cmInstance.DeleteWorkTree("feature/verbose-cli-test", true) + err = cmInstance.DeleteWorkTree("feature/verbose-cli-test", true, codemanager.DeleteWorktreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Worktree deletion should succeed") // Verify the worktree was deleted diff --git a/test/worktree_list_test.go b/test/worktree_list_test.go index c94181a..b4ae4d6 100644 --- a/test/worktree_list_test.go +++ b/test/worktree_list_test.go @@ -35,7 +35,9 @@ func listWorktrees(t *testing.T, setup *TestSetup) ([]status.WorktreeInfo, error require.NoError(t, err) defer os.Chdir(originalDir) - worktrees, err := cmInstance.ListWorktrees() + worktrees, err := cmInstance.ListWorktrees(codemanager.ListWorktreesOpts{ + RepositoryName: ".", + }) return worktrees, err } @@ -72,7 +74,9 @@ func runListCommand(t *testing.T, setup *TestSetup, args ...string) (string, err defer os.Chdir(originalDir) // Call ListWorktrees directly - worktrees, err := cmInstance.ListWorktrees() + worktrees, err := cmInstance.ListWorktrees(codemanager.ListWorktreesOpts{ + RepositoryName: ".", + }) if err != nil { return err.Error(), err } diff --git a/test/worktree_load_test.go b/test/worktree_load_test.go index 38643bb..03b994b 100644 --- a/test/worktree_load_test.go +++ b/test/worktree_load_test.go @@ -26,7 +26,9 @@ func loadWorktree(t *testing.T, setup *TestSetup, branchArg string) error { restore := safeChdir(t, setup.RepoPath) defer restore() - return cmInstance.LoadWorktree(branchArg) + return cmInstance.LoadWorktree(branchArg, codemanager.LoadWorktreeOpts{ + RepositoryName: ".", + }) } func TestLoadWorktreeRepoModeWithOptionalRemote(t *testing.T) { @@ -77,7 +79,7 @@ func TestLoadWorktreeRepoModeWithOptionalRemote(t *testing.T) { t.Run("LoadEmptyArgument", func(t *testing.T) { err := loadWorktree(t, setup, "") assert.Error(t, err) - assert.ErrorIs(t, err, codemanager.ErrArgumentEmpty) + assert.Contains(t, err.Error(), "failed to get branch name") }) } diff --git a/test/worktree_open_test.go b/test/worktree_open_test.go index e662aaf..7f69d64 100644 --- a/test/worktree_open_test.go +++ b/test/worktree_open_test.go @@ -36,11 +36,15 @@ func TestOpenWorktreeRepoModeExisting(t *testing.T) { require.NoError(t, err) defer os.Chdir(originalDir) - err = cmInstance.CreateWorkTree("feature/existing-ide") + err = cmInstance.CreateWorkTree("feature/existing-ide", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Worktree creation should succeed") // Open the worktree with IDE (dummy IDE will print the path to stdout) - err = cmInstance.OpenWorktree("feature/existing-ide", "dummy") + err = cmInstance.OpenWorktree("feature/existing-ide", "dummy", codemanager.OpenWorktreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Opening worktree with IDE should succeed") // Verify that the original repository path in status.yaml is correct (not the worktree path) @@ -84,7 +88,9 @@ func TestOpenWorktreeRepoModeNonExistent(t *testing.T) { require.NoError(t, err) defer os.Chdir(originalDir) - err = cmInstance.OpenWorktree("non-existent-branch", "dummy") + err = cmInstance.OpenWorktree("non-existent-branch", "dummy", codemanager.OpenWorktreeOpts{ + RepositoryName: ".", + }) assert.Error(t, err, "Opening non-existent worktree should fail") assert.ErrorIs(t, err, codemanager.ErrWorktreeNotInStatus, "Error should mention worktree not found") } @@ -111,11 +117,15 @@ func TestOpenWorktreeRepoModeWithUnsupportedIDE(t *testing.T) { require.NoError(t, err) defer os.Chdir(originalDir) - err = cmInstance.CreateWorkTree("feature/unsupported-ide") + err = cmInstance.CreateWorkTree("feature/unsupported-ide", codemanager.CreateWorkTreeOpts{ + RepositoryName: ".", + }) require.NoError(t, err, "Worktree creation should succeed") // Try to open with unsupported IDE - err = cmInstance.OpenWorktree("feature/unsupported-ide", "unsupported-ide") + err = cmInstance.OpenWorktree("feature/unsupported-ide", "unsupported-ide", codemanager.OpenWorktreeOpts{ + RepositoryName: ".", + }) assert.Error(t, err, "Opening with unsupported IDE should fail") assert.ErrorIs(t, err, ide.ErrUnsupportedIDE, "Error should mention unsupported IDE") }