From 2876da91d4b243b563613366bfe99ec9ab427d71 Mon Sep 17 00:00:00 2001 From: jcleira Date: Tue, 10 Feb 2026 07:19:41 +0100 Subject: [PATCH] UX Improvements with Parallel Processing and JSON Output Objective Enhance the CLI user experience with new features and improve code quality through pointer semantics and expanded test coverage. Why These changes improve usability, performance, and maintainability. They provide better feedback during operations, structured output, safer memory handling, and stronger test reliability. How - Introduce CLI output improvements. - Improve performance. - Enhance dashboard behavior. - Extend command functionality --- cmd/branch/branch.go | 7 +- cmd/branch/cleanup.go | 39 +-- cmd/branch/ignore.go | 10 +- cmd/branch/list.go | 13 +- cmd/config/init.go | 10 +- cmd/config/set.go | 19 +- cmd/config/setup.go | 2 +- cmd/config/show.go | 51 +++- cmd/create/create.go | 63 +++-- cmd/delete/delete.go | 21 +- cmd/list/list.go | 11 +- cmd/root.go | 6 +- cmd/switch/switch.go | 16 +- go.mod | 7 +- go.sum | 6 +- pkg/branch/service.go | 197 +++++++++------ pkg/branch/service_test.go | 243 ++++++++++++++++++ pkg/config/config_test.go | 260 +++++++++++++++++++ pkg/git/worktree.go | 14 +- pkg/git/worktree_test.go | 389 +++++++++++++++++++++++++++++ pkg/shell/navigation.go | 8 +- pkg/ui/commands/messages.go | 24 ++ pkg/ui/commands/prompt.go | 20 ++ pkg/ui/commands/spinner.go | 94 +++++++ pkg/ui/dashboard/dashboard.go | 25 +- pkg/ui/dashboard/workspace_list.go | 43 +++- pkg/workspace/service.go | 155 ++++++++---- pkg/workspace/service_test.go | 306 +++++++++++++++++++++++ pkg/workspace/types.go | 6 +- 29 files changed, 1816 insertions(+), 249 deletions(-) create mode 100644 pkg/branch/service_test.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/git/worktree_test.go create mode 100644 pkg/ui/commands/spinner.go create mode 100644 pkg/workspace/service_test.go diff --git a/cmd/branch/branch.go b/cmd/branch/branch.go index 4b1819f..76c86da 100644 --- a/cmd/branch/branch.go +++ b/cmd/branch/branch.go @@ -8,9 +8,10 @@ import ( ) var BranchCmd = &cobra.Command{ - Use: "branch", - Short: "Manage branches in main repositories", - Long: `Manage branches in main repositories, including listing and cleaning up orphaned branches.`, + Use: "branch", + Aliases: []string{"b"}, + Short: "Manage branches in main repositories", + Long: `Manage branches in main repositories, including listing and cleaning up orphaned branches.`, } func init() { diff --git a/cmd/branch/cleanup.go b/cmd/branch/cleanup.go index 1120848..79dfd07 100644 --- a/cmd/branch/cleanup.go +++ b/cmd/branch/cleanup.go @@ -11,9 +11,10 @@ import ( ) var cleanupCmd = &cobra.Command{ - Use: "cleanup", - Short: "Delete orphaned branches", - Long: `Delete branches that don't have an associated workspace (orphaned branches).`, + Use: "cleanup", + Short: "Delete orphaned branches", + Long: `Delete branches that don't have an associated workspace (orphaned branches).`, + Example: ` workspace branch cleanup`, Run: func(_ *cobra.Command, _ []string) { cleanupBranches() }, @@ -28,25 +29,29 @@ func cleanupBranches() { plan, err := svc.PlanCleanup() if err != nil { - commands.PrintError(fmt.Sprintf("Failed to plan cleanup: %v", err)) + commands.PrintErrorf("Failed to plan cleanup: %v", err) return } if len(plan.OrphanedBranches) == 0 { if plan.SkippedIgnored > 0 { - commands.PrintSuccess(fmt.Sprintf("No orphaned branches found! (%d ignored branches skipped)", plan.SkippedIgnored)) + commands.PrintSuccessf("No orphaned branches found! (%d ignored branches skipped)", plan.SkippedIgnored) } else { commands.PrintSuccess("No orphaned branches found!") } return } - commands.PrintWarning(fmt.Sprintf("Found %d orphaned branch(es):", len(plan.OrphanedBranches))) + commands.PrintWarningf("Found %d orphaned branch(es):", len(plan.OrphanedBranches)) if plan.SkippedIgnored > 0 { - commands.PrintInfo(fmt.Sprintf("(%d ignored branches skipped)", plan.SkippedIgnored)) + commands.PrintInfof("(%d ignored branches skipped)", plan.SkippedIgnored) } for _, ob := range plan.OrphanedBranches { - fmt.Printf(" - %s: %s\n", ob.RepoName, ob.BranchName) + if ob.HasUnpushed { + fmt.Printf(" - %s: %s %s\n", ob.RepoName, ob.BranchName, commands.ColorWarning(fmt.Sprintf("(%d unpushed)", ob.UnpushedCount))) + } else { + fmt.Printf(" - %s: %s\n", ob.RepoName, ob.BranchName) + } } if !commands.PromptYesNo("\nDo you want to delete these branches? (y/n): ") { @@ -58,21 +63,21 @@ func cleanupBranches() { result, err := svc.ExecuteCleanup(plan, skipBranches) if err != nil { - commands.PrintError(fmt.Sprintf("Failed to execute cleanup: %v", err)) + commands.PrintErrorf("Failed to execute cleanup: %v", err) return } displayCleanupResult(result) } -func promptForUnpushedBranches(plan *branch.CleanupPlan) []string { +func promptForUnpushedBranches(plan branch.CleanupPlan) []string { var skipBranches []string for _, ob := range plan.OrphanedBranches { if ob.HasUnpushed { - commands.PrintWarning(fmt.Sprintf("Branch '%s' in %s has %d unpushed commit(s)", ob.BranchName, ob.RepoName, ob.UnpushedCount)) + commands.PrintWarningf("Branch '%s' in %s has %d unpushed commit(s)", ob.BranchName, ob.RepoName, ob.UnpushedCount) if !commands.PromptYesNo("Delete anyway? (y/n): ") { - commands.PrintInfo(fmt.Sprintf("Skipping branch '%s'", ob.BranchName)) + commands.PrintInfof("Skipping branch '%s'", ob.BranchName) skipBranches = append(skipBranches, fmt.Sprintf("%s:%s", ob.RepoName, ob.BranchName)) } } @@ -81,21 +86,21 @@ func promptForUnpushedBranches(plan *branch.CleanupPlan) []string { return skipBranches } -func displayCleanupResult(result *branch.CleanupResult) { +func displayCleanupResult(result branch.CleanupResult) { fmt.Printf("\n") for _, d := range result.Deleted { - commands.PrintSuccess(fmt.Sprintf("Deleted %s in %s", d.BranchName, d.RepoName)) + commands.PrintSuccessf("Deleted %s in %s", d.BranchName, d.RepoName) } for _, f := range result.Failed { - commands.PrintError(fmt.Sprintf("Failed to delete %s in %s: %v", f.BranchName, f.RepoName, f.Error)) + commands.PrintErrorf("Failed to delete %s in %s: %v", f.BranchName, f.RepoName, f.Error) } if len(result.Deleted) > 0 { - commands.PrintSuccess(fmt.Sprintf("Deleted %d branch(es)", len(result.Deleted))) + commands.PrintSuccessf("Deleted %d branch(es)", len(result.Deleted)) } if len(result.Failed) > 0 { - commands.PrintWarning(fmt.Sprintf("Failed to delete %d branch(es)", len(result.Failed))) + commands.PrintWarningf("Failed to delete %d branch(es)", len(result.Failed)) } } diff --git a/cmd/branch/ignore.go b/cmd/branch/ignore.go index 516310d..e184077 100644 --- a/cmd/branch/ignore.go +++ b/cmd/branch/ignore.go @@ -22,10 +22,10 @@ var ignoreAddCmd = &cobra.Command{ Run: func(_ *cobra.Command, args []string) { pattern := args[0] if err := cmd.ConfigManager.AddIgnoredBranch(pattern); err != nil { - commands.PrintError(fmt.Sprintf("Failed to add pattern: %v", err)) + commands.PrintErrorf("Failed to add pattern: %v", err) return } - commands.PrintSuccess(fmt.Sprintf("Added pattern '%s' to ignore list", pattern)) + commands.PrintSuccessf("Added pattern '%s' to ignore list", pattern) }, } @@ -36,10 +36,10 @@ var ignoreRemoveCmd = &cobra.Command{ Run: func(_ *cobra.Command, args []string) { pattern := args[0] if err := cmd.ConfigManager.RemoveIgnoredBranch(pattern); err != nil { - commands.PrintError(fmt.Sprintf("Failed to remove pattern: %v", err)) + commands.PrintErrorf("Failed to remove pattern: %v", err) return } - commands.PrintSuccess(fmt.Sprintf("Removed pattern '%s' from ignore list", pattern)) + commands.PrintSuccessf("Removed pattern '%s' from ignore list", pattern) }, } @@ -65,7 +65,7 @@ var ignoreClearCmd = &cobra.Command{ Short: "Clear all ignored branch patterns", Run: func(_ *cobra.Command, _ []string) { if err := cmd.ConfigManager.ClearIgnoredBranches(); err != nil { - commands.PrintError(fmt.Sprintf("Failed to clear ignored patterns: %v", err)) + commands.PrintErrorf("Failed to clear ignored patterns: %v", err) return } commands.PrintSuccess("Cleared all ignored branch patterns") diff --git a/cmd/branch/list.go b/cmd/branch/list.go index 78dbe06..0a5342e 100644 --- a/cmd/branch/list.go +++ b/cmd/branch/list.go @@ -12,9 +12,12 @@ import ( ) var listCmd = &cobra.Command{ - Use: "list", - Short: "List all branches and their associated workspaces", - Long: `List all branches in main repositories, showing which workspaces they belong to and if they have unpushed commits.`, + Use: "list", + Aliases: []string{"ls"}, + Short: "List all branches and their associated workspaces", + Long: `List all branches in main repositories, showing which workspaces they belong to and if they have unpushed commits.`, + Example: ` workspace branch list + workspace b ls`, Run: func(_ *cobra.Command, _ []string) { listBranches() }, @@ -29,7 +32,7 @@ func listBranches() { output, err := svc.List() if err != nil { - commands.PrintError(fmt.Sprintf("Failed to list branches: %v", err)) + commands.PrintErrorf("Failed to list branches: %v", err) return } @@ -41,7 +44,7 @@ func listBranches() { displayBranchList(output) } -func displayBranchList(output *branch.ListOutput) { +func displayBranchList(output branch.ListOutput) { for _, repo := range output.Repositories { fmt.Printf("\n%s\n", commands.ColorInfo(fmt.Sprintf("=== Repository: %s ===", repo.RepoName))) diff --git a/cmd/config/init.go b/cmd/config/init.go index 27279c1..c9339ab 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -46,11 +46,11 @@ func initShellIntegration() { functionContent = shell.GenerateFishFunction() if err := os.MkdirAll(fishConfigDir, 0o755); err != nil { - commands.PrintError(fmt.Sprintf("Failed to create fish config directory: %v", err)) + commands.PrintErrorf("Failed to create fish config directory: %v", err) return } default: - commands.PrintError(fmt.Sprintf("Unsupported shell: %s", shellName)) + commands.PrintErrorf("Unsupported shell: %s", shellName) fmt.Println("Supported shells: bash, zsh, fish") return } @@ -61,16 +61,16 @@ func initShellIntegration() { if shellName == "fish" { if err := os.WriteFile(rcFile, []byte(functionContent), 0o644); err != nil { - commands.PrintError(fmt.Sprintf("Failed to write fish function: %v", err)) + commands.PrintErrorf("Failed to write fish function: %v", err) return } - commands.PrintSuccess(fmt.Sprintf("Fish function written to: %s", rcFile)) + commands.PrintSuccessf("Fish function written to: %s", rcFile) commands.PrintInfo("Restart your shell or run 'source ~/.config/fish/config.fish' to use the 'w' command") } else { fmt.Printf("Add this function to your %s:\n\n", commands.InfoStyle.Render(rcFile)) fmt.Println(functionContent) fmt.Println() - commands.PrintInfo("After adding, restart your shell or run 'source " + rcFile + "' to use the 'w' command") + commands.PrintInfof("After adding, restart your shell or run 'source %s' to use the 'w' command", rcFile) } fmt.Println() diff --git a/cmd/config/set.go b/cmd/config/set.go index de91b88..fa27a26 100644 --- a/cmd/config/set.go +++ b/cmd/config/set.go @@ -14,7 +14,10 @@ var setCmd = &cobra.Command{ Use: "set ", Short: "Set configuration value", Long: `Set a configuration value. Available keys: workspaces-dir, repos-dir, claude-dir`, - Args: cobra.ExactArgs(2), + Example: ` workspace config set repos-dir ~/Projects/repos + workspace config set workspaces-dir ~/dev/workspaces + workspace config set claude-dir ~/shared/.claude`, + Args: cobra.ExactArgs(2), Run: func(_ *cobra.Command, args []string) { setConfigValue(args[0], args[1]) }, @@ -28,33 +31,33 @@ func setConfigValue(key, value string) { switch key { case "workspaces-dir": if err := cmd.ConfigManager.SetWorkspacesDir(value); err != nil { - commands.PrintError(fmt.Sprintf("Failed to set workspaces directory: %v", err)) + commands.PrintErrorf("Failed to set workspaces directory: %v", err) return } cfg := cmd.ConfigManager.GetConfig() cmd.WorkspaceManager = workspace.NewManager(cfg.WorkspacesDir, cfg.ReposDir, cfg.ClaudeDir) - commands.PrintSuccess(fmt.Sprintf("Workspaces directory set to: %s", cfg.WorkspacesDir)) + commands.PrintSuccessf("Workspaces directory set to: %s", cfg.WorkspacesDir) case "repos-dir": if err := cmd.ConfigManager.SetReposDir(value); err != nil { - commands.PrintError(fmt.Sprintf("Failed to set repos directory: %v", err)) + commands.PrintErrorf("Failed to set repos directory: %v", err) return } cfg := cmd.ConfigManager.GetConfig() cmd.WorkspaceManager = workspace.NewManager(cfg.WorkspacesDir, cfg.ReposDir, cfg.ClaudeDir) - commands.PrintSuccess(fmt.Sprintf("Repos directory set to: %s", cfg.ReposDir)) + commands.PrintSuccessf("Repos directory set to: %s", cfg.ReposDir) case "claude-dir": if err := cmd.ConfigManager.SetClaudeDir(value); err != nil { - commands.PrintError(fmt.Sprintf("Failed to set claude directory: %v", err)) + commands.PrintErrorf("Failed to set claude directory: %v", err) return } cfg := cmd.ConfigManager.GetConfig() cmd.WorkspaceManager = workspace.NewManager(cfg.WorkspacesDir, cfg.ReposDir, cfg.ClaudeDir) - commands.PrintSuccess(fmt.Sprintf("Claude directory set to: %s", cfg.ClaudeDir)) + commands.PrintSuccessf("Claude directory set to: %s", cfg.ClaudeDir) default: - commands.PrintError(fmt.Sprintf("Unknown configuration key: %s", key)) + commands.PrintErrorf("Unknown configuration key: %s", key) fmt.Println("Available keys: workspaces-dir, repos-dir, claude-dir") } } diff --git a/cmd/config/setup.go b/cmd/config/setup.go index 847adf6..b787500 100644 --- a/cmd/config/setup.go +++ b/cmd/config/setup.go @@ -35,7 +35,7 @@ func runInteractiveSetup() { cfg.ClaudeDir, ) if err != nil { - commands.PrintError(fmt.Sprintf("Setup wizard failed: %v", err)) + commands.PrintErrorf("Setup wizard failed: %v", err) os.Exit(1) } diff --git a/cmd/config/show.go b/cmd/config/show.go index e4dee9f..d0a4559 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "os" "github.com/spf13/cobra" @@ -12,7 +13,7 @@ import ( var showCmd = &cobra.Command{ Use: "show", Short: "Show current configuration", - Long: `Display the current workspace configuration.`, + Long: `Display the current workspace configuration with health check information.`, Run: func(_ *cobra.Command, _ []string) { showConfig() }, @@ -32,8 +33,50 @@ func showConfig() { fmt.Printf("Config file: %s\n", commands.InfoStyle.Render(cmd.ConfigManager.GetConfigPath())) fmt.Println() - fmt.Printf("Workspaces directory: %s\n", commands.SuccessStyle.Render(config.WorkspacesDir)) - fmt.Printf("Repos directory: %s\n", commands.SuccessStyle.Render(config.ReposDir)) - fmt.Printf("Claude directory: %s\n", commands.SuccessStyle.Render(config.ClaudeDir)) + workspacesStatus := checkDirStatus(config.WorkspacesDir, "workspaces") + reposStatus := checkDirStatus(config.ReposDir, "repos") + claudeStatus := checkDirStatus(config.ClaudeDir, "") + + fmt.Printf("Workspaces directory: %s %s\n", commands.SuccessStyle.Render(config.WorkspacesDir), workspacesStatus) + fmt.Printf("Repos directory: %s %s\n", commands.SuccessStyle.Render(config.ReposDir), reposStatus) + fmt.Printf("Claude directory: %s %s\n", commands.SuccessStyle.Render(config.ClaudeDir), claudeStatus) fmt.Println() } + +func checkDirStatus(path, dirType string) string { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return commands.ColorWarning("(missing)") + } + if err != nil { + return commands.ColorWarning("(error)") + } + if !info.IsDir() { + return commands.ColorWarning("(not a directory)") + } + + entries, err := os.ReadDir(path) + if err != nil { + return commands.ColorSuccess("(exists)") + } + + count := 0 + for _, e := range entries { + if e.IsDir() && e.Name() != ".claude" && !isHiddenDir(e.Name()) { + count++ + } + } + + switch dirType { + case "workspaces": + return commands.ColorSuccess(fmt.Sprintf("(exists, %d workspace(s))", count)) + case "repos": + return commands.ColorSuccess(fmt.Sprintf("(exists, %d repo(s))", count)) + default: + return commands.ColorSuccess("(exists)") + } +} + +func isHiddenDir(name string) bool { + return name != "" && name[0] == '.' +} diff --git a/cmd/create/create.go b/cmd/create/create.go index 6cbe1a9..abbb90c 100644 --- a/cmd/create/create.go +++ b/cmd/create/create.go @@ -14,10 +14,14 @@ import ( ) var createCmd = &cobra.Command{ - Use: "create ", - Short: "Create a new workspace", - Long: `Create a new workspace directory using all repositories in the repos directory.`, - Args: cobra.ExactArgs(1), + Use: "create ", + Aliases: []string{"c"}, + Short: "Create a new workspace", + Long: `Create a new workspace directory using all repositories in the repos directory.`, + Example: ` workspace create myfeature + workspace create bugfix-123 + workspace c quick-test`, + Args: cobra.ExactArgs(1), Run: func(_ *cobra.Command, args []string) { name := args[0] createWorkspace(name) @@ -32,26 +36,35 @@ func createWorkspace(name string) { svc := workspace.NewService(cmd.WorkspaceManager) if workspacePath, err := svc.GetPath(name); err == nil { - commands.PrintWarning(fmt.Sprintf("Workspace 'workspace-%s' already exists", name)) + commands.PrintWarningf("Workspace 'workspace-%s' already exists", name) if !commands.PromptYesNo("Do you want to use the existing workspace? (y/n): ") { commands.PrintInfo("Creation canceled") return } - commands.PrintSuccess(fmt.Sprintf("Using existing workspace at: %s", workspacePath)) + commands.PrintSuccessf("Using existing workspace at: %s", workspacePath) ws := workspace.Workspace{Name: name, Path: workspacePath} shell.NavigateToWorkspace(ws) return } + spinner := commands.NewSpinner(fmt.Sprintf("Creating workspace '%s'...", name)) + spinner.Start() + input := workspace.CreateInput{ Name: name, + OnProgress: func(message string) { + spinner.UpdateMessage(message) + }, } - commands.PrintInfo(fmt.Sprintf("Creating workspace '%s'...", name)) - output, err := svc.Create(input) + spinner.Stop() + if err != nil { - commands.PrintError(fmt.Sprintf("Failed to create workspace: %v", err)) + commands.PrintErrorf("Failed to create workspace: %v", err) + if os.IsNotExist(err) || fmt.Sprintf("%v", err) == fmt.Sprintf("no repositories found in %s", cmd.WorkspaceManager.ReposDir) { + commands.PrintInfo("Run 'workspace config setup' to configure your directories") + } os.Exit(1) } @@ -65,48 +78,52 @@ func createWorkspace(name string) { shell.NavigateToWorkspace(ws) } -func displayCreateResults(output *workspace.CreateOutput, name string) { +func displayCreateResults(output workspace.CreateOutput, name string) { if output.AlreadyExists { - commands.PrintWarning(fmt.Sprintf("Workspace 'workspace-%s' already exists", name)) + commands.PrintWarningf("Workspace 'workspace-%s' already exists", name) } else { - commands.PrintSuccess(fmt.Sprintf("Workspace created at: %s", output.WorkspacePath)) + commands.PrintSuccessf("Created workspace at %s", output.WorkspacePath) } for _, sync := range output.SyncResults { if sync.Error != nil { - commands.PrintWarning(fmt.Sprintf("Failed to sync %s: %v", sync.RepoName, sync.Error)) + commands.PrintWarningf("Failed to sync %s: %v", sync.RepoName, sync.Error) } else if sync.Fetched && sync.Pulled { - commands.PrintSuccess(fmt.Sprintf("Updated %s", sync.RepoName)) + commands.PrintSuccessf("Synced %s", sync.RepoName) } } for _, repo := range output.CreatedRepos { if output.WorkspaceType == workspace.WorkspaceTypeWorktree { if repo.WasExisting { - commands.PrintInfo(fmt.Sprintf("Checked out existing branch '%s' for %s", repo.BranchName, repo.Name)) + commands.PrintInfof("Checked out existing branch '%s' for %s", repo.BranchName, repo.Name) } else { - commands.PrintSuccess(fmt.Sprintf("Created worktree for %s (new branch: %s)", repo.Name, repo.BranchName)) + commands.PrintSuccessf("Created worktree for %s (branch: %s)", repo.Name, repo.BranchName) } } else { - commands.PrintSuccess(fmt.Sprintf("Cloned %s", repo.Name)) + commands.PrintSuccessf("Cloned %s", repo.Name) } } for _, repo := range output.FailedRepos { - commands.PrintError(fmt.Sprintf("Failed to create %s: %v", repo.Name, repo.Error)) + commands.PrintErrorf("Failed to create %s: %v", repo.Name, repo.Error) } - if len(output.CreatedRepos) > 0 { + if len(output.CreatedRepos) == 0 && len(output.FailedRepos) == 0 { + commands.PrintWarning("No repositories found in repos directory") + commands.PrintInfo("Workspace created but contains no repos") + commands.PrintInfo("Add repositories to the repos directory and recreate") + } else if len(output.CreatedRepos) > 0 { if output.WorkspaceType == workspace.WorkspaceTypeWorktree { - commands.PrintSuccess(fmt.Sprintf("Created %d worktrees!", len(output.CreatedRepos))) + commands.PrintSuccessf("Created %d worktree(s)", len(output.CreatedRepos)) } else { - commands.PrintSuccess(fmt.Sprintf("Cloned %d repositories!", len(output.CreatedRepos))) + commands.PrintSuccessf("Cloned %d repository/ies", len(output.CreatedRepos)) } } if len(output.FailedRepos) > 0 { - commands.PrintWarning(fmt.Sprintf("%d repositories failed", len(output.FailedRepos))) + commands.PrintWarningf("%d repository/ies failed", len(output.FailedRepos)) } - commands.PrintSuccess(fmt.Sprintf("Workspace 'workspace-%s' is ready!", name)) + commands.PrintSuccessf("Workspace 'workspace-%s' is ready", name) } diff --git a/cmd/delete/delete.go b/cmd/delete/delete.go index 2387248..8b7809b 100644 --- a/cmd/delete/delete.go +++ b/cmd/delete/delete.go @@ -12,9 +12,12 @@ import ( ) var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a workspace", - Long: `Delete a workspace and all its contents, including associated git branches.`, + Use: "delete ", + Aliases: []string{"d", "rm"}, + Short: "Delete a workspace", + Long: `Delete a workspace and all its contents, including associated git branches.`, + Example: ` workspace delete myfeature + workspace rm bugfix-123`, Args: cobra.ExactArgs(1), ValidArgsFunction: cmd.WorkspaceCompletionFunc, Run: func(_ *cobra.Command, args []string) { @@ -37,7 +40,7 @@ func deleteWorkspace(name string) { svc := workspace.NewService(cmd.WorkspaceManager) if _, err := svc.GetPath(name); err != nil { - commands.PrintError(fmt.Sprintf("Workspace 'workspace-%s' not found", name)) + commands.PrintErrorf("Workspace 'workspace-%s' not found", name) return } @@ -63,23 +66,23 @@ func deleteWorkspace(name string) { defaultPath, err := svc.GetPath("default") if err != nil { - commands.PrintError("Could not navigate to default workspace: " + err.Error()) + commands.PrintErrorf("Could not navigate to default workspace: %v", err) return } - commands.PrintSuccess(fmt.Sprintf("Workspace 'workspace-%s' deleted successfully", name)) + commands.PrintSuccessf("Workspace 'workspace-%s' deleted successfully", name) commands.PrintInfo("Switching to default workspace") fmt.Printf("cd %s\n", defaultPath) } -func displayDeleteResults(output *workspace.DeleteOutput) { +func displayDeleteResults(output workspace.DeleteOutput) { for _, b := range output.DeletedBranches { - commands.PrintSuccess(fmt.Sprintf("Deleted branch '%s' in %s", b.BranchName, b.RepoName)) + commands.PrintSuccessf("Deleted branch '%s' in %s", b.BranchName, b.RepoName) } for _, b := range output.SkippedBranches { if b.Error != nil { - commands.PrintWarning(fmt.Sprintf("Failed to delete branch '%s' in %s: %v", b.BranchName, b.RepoName, b.Error)) + commands.PrintWarningf("Failed to delete branch '%s' in %s: %v", b.BranchName, b.RepoName, b.Error) } } } diff --git a/cmd/list/list.go b/cmd/list/list.go index b5974bf..f36382a 100644 --- a/cmd/list/list.go +++ b/cmd/list/list.go @@ -15,9 +15,12 @@ import ( ) var listCmd = &cobra.Command{ - Use: "list", - Short: "List all workspaces", - Long: `Display all existing workspaces and their contents.`, + Use: "list", + Aliases: []string{"ls"}, + Short: "List all workspaces", + Long: `Display all existing workspaces and their contents.`, + Example: ` workspace list + workspace ls`, Run: func(_ *cobra.Command, _ []string) { ListWorkspaces() }, @@ -32,7 +35,7 @@ func ListWorkspaces() { workspaces, err := cmd.WorkspaceManager.GetWorkspaces() if err != nil { - commands.PrintError(fmt.Sprintf("Failed to get workspaces: %v", err)) + commands.PrintErrorf("Failed to get workspaces: %v", err) return } diff --git a/cmd/root.go b/cmd/root.go index 87d98b0..c991d80 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,7 +75,7 @@ func runInteractiveWorkspaceSelector() { for { workspaces, err := WorkspaceManager.GetWorkspaces() if err != nil { - commands.PrintError(fmt.Sprintf("Failed to get workspaces: %v", err)) + commands.PrintErrorf("Failed to get workspaces: %v", err) return } @@ -87,7 +87,7 @@ func runInteractiveWorkspaceSelector() { result, err := dashboard.RunDashboard(WorkspaceManager, ConfigManager) if err != nil { - commands.PrintError(fmt.Sprintf("Dashboard error: %v", err)) + commands.PrintErrorf("Dashboard error: %v", err) return } @@ -100,7 +100,7 @@ func runInteractiveWorkspaceSelector() { cfg.ClaudeDir, ) if err != nil { - commands.PrintError(fmt.Sprintf("Setup wizard failed: %v", err)) + commands.PrintErrorf("Setup wizard failed: %v", err) return } if setupResult.Completed { diff --git a/cmd/switch/switch.go b/cmd/switch/switch.go index 714dcdd..383d920 100644 --- a/cmd/switch/switch.go +++ b/cmd/switch/switch.go @@ -12,9 +12,12 @@ import ( ) var switchCmd = &cobra.Command{ - Use: "switch ", - Short: "Switch to a workspace", - Long: `Show the path to switch to a workspace directory.`, + Use: "switch ", + Aliases: []string{"sw"}, + Short: "Switch to a workspace", + Long: `Show the path to switch to a workspace directory.`, + Example: ` workspace switch myfeature + workspace sw default`, Args: cobra.ExactArgs(1), ValidArgsFunction: cmd.WorkspaceCompletionFunc, Run: func(_ *cobra.Command, args []string) { @@ -30,12 +33,15 @@ func init() { func switchWorkspace(name string) { workspacePath, err := cmd.WorkspaceManager.GetWorkspacePath(name) if err != nil { - commands.PrintError(err.Error()) + commands.PrintErrorf("Workspace 'workspace-%s' not found", name) + commands.PrintInfo("Run 'workspace list' to see available workspaces") + commands.PrintInfof("Run 'workspace create %s' to create a new workspace", name) + fmt.Println() fmt.Println("Available workspaces:") listpkg.ListWorkspaces() return } - commands.PrintSuccess(fmt.Sprintf("Switching to workspace: workspace-%s", name)) + commands.PrintSuccessf("Switching to workspace: workspace-%s", name) fmt.Printf("cd %s\n", workspacePath) } diff --git a/go.mod b/go.mod index f5b8b3f..d02ce7c 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module github.com/jcleira/workspace -go 1.23.0 +go 1.24.0 require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.9.1 + golang.org/x/sync v0.15.0 + golang.org/x/term v0.39.0 ) require ( @@ -28,7 +30,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 02dd041..ce37a87 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/branch/service.go b/pkg/branch/service.go index c712340..65f0335 100644 --- a/pkg/branch/service.go +++ b/pkg/branch/service.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" "strings" + "sync" + + "golang.org/x/sync/errgroup" "github.com/jcleira/workspace/pkg/git" "github.com/jcleira/workspace/pkg/workspace" @@ -66,102 +69,132 @@ func (s *Service) BuildWorkspaceMapping() (WorkspaceBranchMapping, error) { } // List returns all branches across repositories with workspace mapping. -func (s *Service) List() (*ListOutput, error) { +func (s *Service) List() (ListOutput, error) { reposDir := s.workspaceManager.ReposDir if _, err := os.Stat(reposDir); os.IsNotExist(err) { - return &ListOutput{}, nil + return ListOutput{}, nil } repos, err := os.ReadDir(reposDir) if err != nil { - return nil, fmt.Errorf("failed to read repositories: %w", err) + return ListOutput{}, fmt.Errorf("failed to read repositories: %w", err) } workspaceMapping, err := s.BuildWorkspaceMapping() if err != nil { - return nil, fmt.Errorf("failed to build workspace mapping: %w", err) + return ListOutput{}, fmt.Errorf("failed to build workspace mapping: %w", err) } - output := &ListOutput{ - Repositories: make([]RepositoryBranches, 0), + output := ListOutput{ + Repositories: make([]RepositoryBranches, 0, len(repos)), } + var mu sync.Mutex + var g errgroup.Group + for _, repo := range repos { if !repo.IsDir() { continue } - repoPath := filepath.Join(reposDir, repo.Name()) - branches, err := git.GetAllBranches(repoPath) - if err != nil { - continue - } + repoName := repo.Name() + repoPath := filepath.Join(reposDir, repoName) - defaultBranch, err := git.GetDefaultBranch(repoPath) - if err != nil { - defaultBranch = "" - } - - repoBranches := RepositoryBranches{ - RepoName: repo.Name(), - RepoPath: repoPath, - DefaultBranch: defaultBranch, - Branches: make([]BranchInfo, 0, len(branches)), - } - - for _, branch := range branches { - output.TotalBranches++ - - info := BranchInfo{ - Name: branch, - RepoName: repo.Name(), - RepoPath: repoPath, - IsDefault: branch == defaultBranch, - IsIgnored: git.ShouldIgnoreBranch(branch, s.ignorePatterns), + g.Go(func() error { + branches, err := git.GetAllBranches(repoPath) + if err != nil { + return nil } - if info.IsIgnored { - output.IgnoredCount++ + defaultBranch, err := git.GetDefaultBranch(repoPath) + if err != nil { + defaultBranch = "" } - key := fmt.Sprintf("%s:%s", repo.Name(), branch) - if wsName, ok := workspaceMapping[key]; ok { - info.WorkspaceName = wsName - } else if !info.IsDefault && !info.IsIgnored { - info.IsOrphaned = true - output.OrphanedCount++ + repoBranches := RepositoryBranches{ + RepoName: repoName, + RepoPath: repoPath, + DefaultBranch: defaultBranch, + Branches: make([]BranchInfo, 0, len(branches)), } - hasUnpushed, err := git.HasUnpushedCommits(repoPath, branch) - if err == nil && hasUnpushed { - info.HasUnpushed = true - if count, err := git.GetUnpushedCommitCount(repoPath, branch); err == nil { - info.UnpushedCount = count - } + var branchMu sync.Mutex + var branchGroup errgroup.Group + + localTotalBranches := 0 + localIgnoredCount := 0 + localOrphanedCount := 0 + + for _, branch := range branches { + branchName := branch + branchGroup.Go(func() error { + info := BranchInfo{ + Name: branchName, + RepoName: repoName, + RepoPath: repoPath, + IsDefault: branchName == defaultBranch, + IsIgnored: git.ShouldIgnoreBranch(branchName, s.ignorePatterns), + } + + key := fmt.Sprintf("%s:%s", repoName, branchName) + if wsName, ok := workspaceMapping[key]; ok { + info.WorkspaceName = wsName + } else if !info.IsDefault && !info.IsIgnored { + info.IsOrphaned = true + } + + hasUnpushed, err := git.HasUnpushedCommits(repoPath, branchName) + if err == nil && hasUnpushed { + info.HasUnpushed = true + if count, err := git.GetUnpushedCommitCount(repoPath, branchName); err == nil { + info.UnpushedCount = count + } + } + + if commitTime, commitBy, err := git.GetBranchLastCommit(repoPath, branchName); err == nil { + info.LastCommitTime = commitTime + info.LastCommitBy = commitBy + } + + branchMu.Lock() + repoBranches.Branches = append(repoBranches.Branches, info) + localTotalBranches++ + if info.IsIgnored { + localIgnoredCount++ + } + if info.IsOrphaned { + localOrphanedCount++ + } + branchMu.Unlock() + return nil + }) } - if commitTime, commitBy, err := git.GetBranchLastCommit(repoPath, branch); err == nil { - info.LastCommitTime = commitTime - info.LastCommitBy = commitBy - } + _ = branchGroup.Wait() - repoBranches.Branches = append(repoBranches.Branches, info) - } + mu.Lock() + output.Repositories = append(output.Repositories, repoBranches) + output.TotalBranches += localTotalBranches + output.IgnoredCount += localIgnoredCount + output.OrphanedCount += localOrphanedCount + mu.Unlock() - output.Repositories = append(output.Repositories, repoBranches) + return nil + }) } + _ = g.Wait() return output, nil } // PlanCleanup identifies orphaned branches for cleanup. -func (s *Service) PlanCleanup() (*CleanupPlan, error) { +func (s *Service) PlanCleanup() (CleanupPlan, error) { listOutput, err := s.List() if err != nil { - return nil, err + return CleanupPlan{}, err } - plan := &CleanupPlan{ + plan := CleanupPlan{ OrphanedBranches: make([]OrphanedBranch, 0), SkippedIgnored: listOutput.IgnoredCount, } @@ -183,24 +216,22 @@ func (s *Service) PlanCleanup() (*CleanupPlan, error) { return plan, nil } -// ExecuteCleanup deletes the specified orphaned branches. +// ExecuteCleanup deletes the specified orphaned branches in parallel. // skipBranches contains branch names to skip (format: "repo:branch"). -func (s *Service) ExecuteCleanup(plan *CleanupPlan, skipBranches []string) (*CleanupResult, error) { +func (s *Service) ExecuteCleanup(plan CleanupPlan, skipBranches []string) (CleanupResult, error) { skipSet := make(map[string]bool) for _, skip := range skipBranches { skipSet[skip] = true } - result := &CleanupResult{ - Deleted: make([]BranchDeleteResult, 0), - Skipped: make([]BranchDeleteResult, 0), - Failed: make([]BranchDeleteResult, 0), - } + var deleted, skipped, failed []BranchDeleteResult + var mu sync.Mutex + var g errgroup.Group for _, ob := range plan.OrphanedBranches { key := fmt.Sprintf("%s:%s", ob.RepoName, ob.BranchName) if skipSet[key] { - result.Skipped = append(result.Skipped, BranchDeleteResult{ + skipped = append(skipped, BranchDeleteResult{ RepoName: ob.RepoName, BranchName: ob.BranchName, Reason: "user skipped", @@ -208,21 +239,33 @@ func (s *Service) ExecuteCleanup(plan *CleanupPlan, skipBranches []string) (*Cle continue } - if err := git.DeleteBranch(ob.RepoPath, ob.BranchName); err != nil { - result.Failed = append(result.Failed, BranchDeleteResult{ - RepoName: ob.RepoName, - BranchName: ob.BranchName, - Error: err, - }) - } else { - result.Deleted = append(result.Deleted, BranchDeleteResult{ - RepoName: ob.RepoName, - BranchName: ob.BranchName, - }) - } + g.Go(func() error { + if err := git.DeleteBranch(ob.RepoPath, ob.BranchName); err != nil { + mu.Lock() + failed = append(failed, BranchDeleteResult{ + RepoName: ob.RepoName, + BranchName: ob.BranchName, + Error: err, + }) + mu.Unlock() + } else { + mu.Lock() + deleted = append(deleted, BranchDeleteResult{ + RepoName: ob.RepoName, + BranchName: ob.BranchName, + }) + mu.Unlock() + } + return nil + }) } - return result, nil + _ = g.Wait() + return CleanupResult{ + Deleted: deleted, + Skipped: skipped, + Failed: failed, + }, nil } // DeleteBranch deletes a single branch from a repository. diff --git a/pkg/branch/service_test.go b/pkg/branch/service_test.go new file mode 100644 index 0000000..b4211cc --- /dev/null +++ b/pkg/branch/service_test.go @@ -0,0 +1,243 @@ +package branch + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jcleira/workspace/pkg/workspace" +) + +func TestNewService(t *testing.T) { + t.Parallel() + + wm := workspace.NewManager("/tmp/workspaces", "/tmp/repos", "/tmp/claude") + patterns := []string{"main", "master"} + + svc := NewService(wm, patterns) + + if svc == nil { + t.Fatal("expected non-nil service") + } + if svc.workspaceManager != wm { + t.Error("expected service to use provided workspace manager") + } + if len(svc.ignorePatterns) != 2 { + t.Errorf("expected 2 ignore patterns, got %d", len(svc.ignorePatterns)) + } +} + +func TestService_BuildWorkspaceMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, workspacesDir string) + wantLen int + wantErr bool + }{ + { + name: "empty workspaces returns empty mapping", + setupFunc: func(t *testing.T, workspacesDir string) {}, + wantLen: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + t.Fatal(err) + } + + if tt.setupFunc != nil { + tt.setupFunc(t, workspacesDir) + } + + wm := workspace.NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(wm, nil) + + mapping, err := svc.BuildWorkspaceMapping() + + if (err != nil) != tt.wantErr { + t.Errorf("BuildWorkspaceMapping() error = %v, wantErr %v", err, tt.wantErr) + } + + if len(mapping) != tt.wantLen { + t.Errorf("BuildWorkspaceMapping() returned %d entries, want %d", len(mapping), tt.wantLen) + } + }) + } +} + +func TestService_List(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, reposDir string) + wantErr bool + }{ + { + name: "non-existent repos dir returns empty output", + setupFunc: nil, + wantErr: false, + }, + { + name: "empty repos dir returns empty output", + setupFunc: func(t *testing.T, reposDir string) { + if err := os.MkdirAll(reposDir, 0o755); err != nil { + t.Fatal(err) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + t.Fatal(err) + } + + if tt.setupFunc != nil { + tt.setupFunc(t, reposDir) + } + + wm := workspace.NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(wm, nil) + + output, err := svc.List() + + if (err != nil) != tt.wantErr { + t.Errorf("List() error = %v, wantErr %v", err, tt.wantErr) + } + + _ = output // use output to avoid unused variable warning + }) + } +} + +func TestService_PlanCleanup(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(reposDir, 0o755); err != nil { + t.Fatal(err) + } + + wm := workspace.NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(wm, nil) + + plan, err := svc.PlanCleanup() + if err != nil { + t.Fatalf("PlanCleanup() error = %v", err) + } + + if plan.OrphanedBranches == nil { + t.Error("expected non-nil OrphanedBranches slice") + } +} + +func TestService_ExecuteCleanup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + plan CleanupPlan + skipBranches []string + wantDeleted int + wantSkipped int + }{ + { + name: "empty plan returns empty result", + plan: CleanupPlan{ + OrphanedBranches: []OrphanedBranch{}, + }, + skipBranches: nil, + wantDeleted: 0, + wantSkipped: 0, + }, + { + name: "skipped branches are recorded", + plan: CleanupPlan{ + OrphanedBranches: []OrphanedBranch{ + {RepoName: "repo1", BranchName: "branch1"}, + }, + }, + skipBranches: []string{"repo1:branch1"}, + wantDeleted: 0, + wantSkipped: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + wm := workspace.NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(wm, nil) + + result, err := svc.ExecuteCleanup(tt.plan, tt.skipBranches) + if err != nil { + t.Fatalf("ExecuteCleanup() error = %v", err) + } + + if len(result.Deleted) != tt.wantDeleted { + t.Errorf("Deleted count = %d, want %d", len(result.Deleted), tt.wantDeleted) + } + + if len(result.Skipped) != tt.wantSkipped { + t.Errorf("Skipped count = %d, want %d", len(result.Skipped), tt.wantSkipped) + } + }) + } +} + +func TestService_CheckUnpushed(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + wm := workspace.NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(wm, nil) + + hasUnpushed, count, err := svc.CheckUnpushed("/nonexistent/repo", "main") + + if err == nil { + t.Log("CheckUnpushed on non-existent repo may or may not error depending on git behavior") + } + + if hasUnpushed && count == 0 { + t.Error("if hasUnpushed is true, count should be > 0") + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..28f54b4 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,260 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewConfigManager(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + if cm == nil { + t.Fatal("expected non-nil ConfigManager") + } + if cm.config == nil { + t.Fatal("expected non-nil config") + } +} + +func TestConfigManager_GetConfig(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + cfg := cm.GetConfig() + if cfg == nil { + t.Fatal("GetConfig() returned nil") + } + if cfg.WorkspacesDir == "" { + t.Error("expected non-empty WorkspacesDir") + } + if cfg.ReposDir == "" { + t.Error("expected non-empty ReposDir") + } + if cfg.ClaudeDir == "" { + t.Error("expected non-empty ClaudeDir") + } +} + +func TestConfigManager_SetWorkspacesDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + newDir := filepath.Join(tmpDir, "new-workspaces") + if err := cm.SetWorkspacesDir(newDir); err != nil { + t.Fatalf("SetWorkspacesDir() error = %v", err) + } + + cfg := cm.GetConfig() + if cfg.WorkspacesDir != newDir { + t.Errorf("WorkspacesDir = %s, want %s", cfg.WorkspacesDir, newDir) + } +} + +func TestConfigManager_IgnoredBranches(t *testing.T) { + tests := []struct { + name string + operations func(cm *ConfigManager) error + wantCount int + wantPattern string + }{ + { + name: "add ignored branch pattern", + operations: func(cm *ConfigManager) error { + return cm.AddIgnoredBranch("feature-*") + }, + wantCount: 1, + wantPattern: "feature-*", + }, + { + name: "remove ignored branch pattern", + operations: func(cm *ConfigManager) error { + if err := cm.AddIgnoredBranch("test-*"); err != nil { + return err + } + return cm.RemoveIgnoredBranch("test-*") + }, + wantCount: 0, + }, + { + name: "clear all patterns", + operations: func(cm *ConfigManager) error { + if err := cm.AddIgnoredBranch("pattern1"); err != nil { + return err + } + if err := cm.AddIgnoredBranch("pattern2"); err != nil { + return err + } + return cm.ClearIgnoredBranches() + }, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + t.Setenv("XDG_CONFIG_HOME", configDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + if err := tt.operations(cm); err != nil { + t.Fatalf("operations error = %v", err) + } + + patterns := cm.GetIgnoredBranches() + if len(patterns) != tt.wantCount { + t.Errorf("got %d patterns, want %d", len(patterns), tt.wantCount) + } + + if tt.wantPattern != "" && (len(patterns) == 0 || patterns[0] != tt.wantPattern) { + t.Errorf("pattern = %v, want %s", patterns, tt.wantPattern) + } + }) + } +} + +func TestConfigManager_AddIgnoredBranch_EmptyPattern(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + if err := cm.AddIgnoredBranch(""); err == nil { + t.Error("expected error for empty pattern") + } +} + +func TestConfigManager_AddIgnoredBranch_Duplicate(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + if err := cm.AddIgnoredBranch("test-*"); err != nil { + t.Fatalf("first AddIgnoredBranch() error = %v", err) + } + + if err := cm.AddIgnoredBranch("test-*"); err == nil { + t.Error("expected error for duplicate pattern") + } +} + +func TestConfigManager_RemoveIgnoredBranch_NotFound(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + if err := cm.RemoveIgnoredBranch("nonexistent"); err == nil { + t.Error("expected error for non-existent pattern") + } +} + +func TestConfigManager_IsInitialized(t *testing.T) { + tests := []struct { + name string + setupFunc func(cm *ConfigManager, tmpDir string) + want bool + }{ + { + name: "not initialized by default", + setupFunc: func(cm *ConfigManager, tmpDir string) {}, + want: false, + }, + { + name: "initialized when flag is set", + setupFunc: func(cm *ConfigManager, tmpDir string) { + _ = cm.SetInitialized(true) + }, + want: true, + }, + { + name: "initialized when repos dir has content", + setupFunc: func(cm *ConfigManager, tmpDir string) { + reposDir := cm.GetConfig().ReposDir + _ = os.MkdirAll(filepath.Join(reposDir, "some-repo"), 0o755) + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + t.Setenv("XDG_CONFIG_HOME", configDir) + + homeDir := filepath.Join(tmpDir, "home") + t.Setenv("HOME", homeDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + tt.setupFunc(cm, tmpDir) + + if got := cm.IsInitialized(); got != tt.want { + t.Errorf("IsInitialized() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfigManager_UpdateConfig(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + cm, err := NewConfigManager() + if err != nil { + t.Fatalf("NewConfigManager() error = %v", err) + } + + newWorkspaces := filepath.Join(tmpDir, "ws") + newRepos := filepath.Join(tmpDir, "repos") + newClaude := filepath.Join(tmpDir, "claude") + + if err := cm.UpdateConfig(newWorkspaces, newRepos, newClaude); err != nil { + t.Fatalf("UpdateConfig() error = %v", err) + } + + cfg := cm.GetConfig() + if cfg.WorkspacesDir != newWorkspaces { + t.Errorf("WorkspacesDir = %s, want %s", cfg.WorkspacesDir, newWorkspaces) + } + if cfg.ReposDir != newRepos { + t.Errorf("ReposDir = %s, want %s", cfg.ReposDir, newRepos) + } + if cfg.ClaudeDir != newClaude { + t.Errorf("ClaudeDir = %s, want %s", cfg.ClaudeDir, newClaude) + } +} diff --git a/pkg/git/worktree.go b/pkg/git/worktree.go index 4e2b002..ebd3331 100644 --- a/pkg/git/worktree.go +++ b/pkg/git/worktree.go @@ -119,22 +119,28 @@ func ListWorktrees(mainRepoPath string) ([]WorktreeInfo, error) { lines := strings.Split(strings.TrimSpace(string(output)), "\n") var worktrees []WorktreeInfo var current WorktreeInfo + hasCurrent := false for _, line := range lines { switch { case strings.HasPrefix(line, "worktree "): - if current.Path != "" { + if hasCurrent && current.Path != "" { worktrees = append(worktrees, current) } current = WorktreeInfo{Path: strings.TrimPrefix(line, "worktree ")} + hasCurrent = true case strings.HasPrefix(line, "branch "): - current.Branch = strings.TrimPrefix(line, "branch ") + if hasCurrent { + current.Branch = strings.TrimPrefix(line, "branch ") + } case strings.HasPrefix(line, "HEAD "): - current.Commit = strings.TrimPrefix(line, "HEAD ") + if hasCurrent { + current.Commit = strings.TrimPrefix(line, "HEAD ") + } } } - if current.Path != "" { + if hasCurrent && current.Path != "" { worktrees = append(worktrees, current) } diff --git a/pkg/git/worktree_test.go b/pkg/git/worktree_test.go new file mode 100644 index 0000000..036c972 --- /dev/null +++ b/pkg/git/worktree_test.go @@ -0,0 +1,389 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func initGitRepo(t *testing.T, path string) { + t.Helper() + + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("git", "init") + cmd.Dir = path + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = path + _ = cmd.Run() + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = path + _ = cmd.Run() + + testFile := filepath.Join(path, "README.md") + if err := os.WriteFile(testFile, []byte("# Test"), 0o644); err != nil { + t.Fatal(err) + } + + cmd = exec.Command("git", "add", ".") + cmd.Dir = path + if err := cmd.Run(); err != nil { + t.Fatalf("failed to add files: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = path + if err := cmd.Run(); err != nil { + t.Fatalf("failed to commit: %v", err) + } +} + +func TestIsWorktree(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, path string) + want bool + wantErr bool + }{ + { + name: "non-existent path returns false", + setupFunc: nil, + want: false, + wantErr: false, + }, + { + name: "regular git repo returns false", + setupFunc: func(t *testing.T, path string) { + initGitRepo(t, path) + }, + want: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testPath := filepath.Join(tmpDir, "test-repo") + + if tt.setupFunc != nil { + tt.setupFunc(t, testPath) + } + + got, err := IsWorktree(testPath) + + if (err != nil) != tt.wantErr { + t.Errorf("IsWorktree() error = %v, wantErr %v", err, tt.wantErr) + } + + if got != tt.want { + t.Errorf("IsWorktree() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCreateWorktree(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mainRepoPath := filepath.Join(tmpDir, "main-repo") + initGitRepo(t, mainRepoPath) + + worktreePath := filepath.Join(tmpDir, "worktree") + branchName := "feature-branch" + + err := CreateWorktree(mainRepoPath, worktreePath, branchName) + if err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + if _, err := os.Stat(worktreePath); os.IsNotExist(err) { + t.Error("worktree directory was not created") + } + + isWt, err := IsWorktree(worktreePath) + if err != nil { + t.Fatalf("IsWorktree() error = %v", err) + } + if !isWt { + t.Error("created path should be a worktree") + } +} + +func TestCreateWorktree_BranchAlreadyExists(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mainRepoPath := filepath.Join(tmpDir, "main-repo") + initGitRepo(t, mainRepoPath) + + cmd := exec.Command("git", "branch", "existing-branch") + cmd.Dir = mainRepoPath + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + + worktreePath := filepath.Join(tmpDir, "worktree") + err := CreateWorktree(mainRepoPath, worktreePath, "existing-branch") + + if err == nil { + t.Error("expected error when creating worktree with existing branch name") + } +} + +func TestCheckoutExistingBranch(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mainRepoPath := filepath.Join(tmpDir, "main-repo") + initGitRepo(t, mainRepoPath) + + cmd := exec.Command("git", "branch", "existing-branch") + cmd.Dir = mainRepoPath + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + + worktreePath := filepath.Join(tmpDir, "worktree") + err := CheckoutExistingBranch(mainRepoPath, worktreePath, "existing-branch") + if err != nil { + t.Fatalf("CheckoutExistingBranch() error = %v", err) + } + + if _, err := os.Stat(worktreePath); os.IsNotExist(err) { + t.Error("worktree directory was not created") + } +} + +func TestRemoveWorktree(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mainRepoPath := filepath.Join(tmpDir, "main-repo") + initGitRepo(t, mainRepoPath) + + worktreePath := filepath.Join(tmpDir, "worktree") + if err := CreateWorktree(mainRepoPath, worktreePath, "test-branch"); err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + if err := RemoveWorktree(mainRepoPath, worktreePath); err != nil { + t.Fatalf("RemoveWorktree() error = %v", err) + } + + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Error("worktree directory should have been removed") + } +} + +func TestListWorktrees(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mainRepoPath := filepath.Join(tmpDir, "main-repo") + initGitRepo(t, mainRepoPath) + + worktrees, err := ListWorktrees(mainRepoPath) + if err != nil { + t.Fatalf("ListWorktrees() error = %v", err) + } + + if len(worktrees) != 1 { + t.Errorf("expected 1 worktree (main), got %d", len(worktrees)) + } + + worktreePath := filepath.Join(tmpDir, "worktree") + if err := CreateWorktree(mainRepoPath, worktreePath, "test-branch"); err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + worktrees, err = ListWorktrees(mainRepoPath) + if err != nil { + t.Fatalf("ListWorktrees() error = %v", err) + } + + if len(worktrees) != 2 { + t.Errorf("expected 2 worktrees, got %d", len(worktrees)) + } +} + +func TestBranchExists(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + branchName string + setupFunc func(t *testing.T, repoPath string) + want bool + }{ + { + name: "main branch exists", + branchName: "master", + setupFunc: nil, + want: true, + }, + { + name: "non-existent branch", + branchName: "nonexistent-branch", + setupFunc: nil, + want: false, + }, + { + name: "created branch exists", + branchName: "feature-branch", + setupFunc: func(t *testing.T, repoPath string) { + cmd := exec.Command("git", "branch", "feature-branch") + cmd.Dir = repoPath + _ = cmd.Run() + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + repoPath := filepath.Join(tmpDir, "repo") + initGitRepo(t, repoPath) + + if tt.setupFunc != nil { + tt.setupFunc(t, repoPath) + } + + got, err := BranchExists(repoPath, tt.branchName) + if err != nil { + t.Fatalf("BranchExists() error = %v", err) + } + + if got != tt.want { + t.Errorf("BranchExists() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsBranchCheckedOut(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mainRepoPath := filepath.Join(tmpDir, "main-repo") + initGitRepo(t, mainRepoPath) + + worktreePath := filepath.Join(tmpDir, "worktree") + branchName := "feature-branch" + if err := CreateWorktree(mainRepoPath, worktreePath, branchName); err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + checkedOut, location, err := IsBranchCheckedOut(mainRepoPath, branchName) + if err != nil { + t.Fatalf("IsBranchCheckedOut() error = %v", err) + } + + if !checkedOut { + t.Error("expected branch to be checked out") + } + + realWorktreePath, _ := filepath.EvalSymlinks(worktreePath) + realLocation, _ := filepath.EvalSymlinks(location) + if realLocation != realWorktreePath { + t.Errorf("location = %s, want %s", realLocation, realWorktreePath) + } +} + +func TestGetCurrentBranch(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + repoPath := filepath.Join(tmpDir, "repo") + initGitRepo(t, repoPath) + + branch, err := GetCurrentBranch(repoPath) + if err != nil { + t.Fatalf("GetCurrentBranch() error = %v", err) + } + + if branch != "master" { + t.Errorf("GetCurrentBranch() = %s, want master", branch) + } +} + +func TestGetAllBranches(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + repoPath := filepath.Join(tmpDir, "repo") + initGitRepo(t, repoPath) + + cmd := exec.Command("git", "branch", "branch1") + cmd.Dir = repoPath + _ = cmd.Run() + + cmd = exec.Command("git", "branch", "branch2") + cmd.Dir = repoPath + _ = cmd.Run() + + branches, err := GetAllBranches(repoPath) + if err != nil { + t.Fatalf("GetAllBranches() error = %v", err) + } + + if len(branches) != 3 { + t.Errorf("expected 3 branches, got %d: %v", len(branches), branches) + } +} + +func TestDeleteBranch(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + repoPath := filepath.Join(tmpDir, "repo") + initGitRepo(t, repoPath) + + cmd := exec.Command("git", "branch", "to-delete") + cmd.Dir = repoPath + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + + if err := DeleteBranch(repoPath, "to-delete"); err != nil { + t.Fatalf("DeleteBranch() error = %v", err) + } + + exists, err := BranchExists(repoPath, "to-delete") + if err != nil { + t.Fatalf("BranchExists() error = %v", err) + } + + if exists { + t.Error("branch should have been deleted") + } +} + +func TestDeleteBranch_NotFound(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + repoPath := filepath.Join(tmpDir, "repo") + initGitRepo(t, repoPath) + + err := DeleteBranch(repoPath, "nonexistent") + if err != nil { + t.Error("DeleteBranch() should not error for non-existent branch") + } +} diff --git a/pkg/shell/navigation.go b/pkg/shell/navigation.go index 7c0bf3a..1c0e8f7 100644 --- a/pkg/shell/navigation.go +++ b/pkg/shell/navigation.go @@ -12,7 +12,7 @@ import ( // NavigateToWorkspace changes to the workspace directory and starts a new shell. func NavigateToWorkspace(ws workspace.Workspace) { - commands.PrintSuccess(fmt.Sprintf("Navigating to: %s", filepath.Base(ws.Path))) + commands.PrintSuccessf("Navigating to: %s", filepath.Base(ws.Path)) shell := os.Getenv("SHELL") if shell == "" { @@ -20,13 +20,13 @@ func NavigateToWorkspace(ws workspace.Workspace) { } if err := os.Chdir(ws.Path); err != nil { - commands.PrintError(fmt.Sprintf("Failed to change directory: %v", err)) + commands.PrintErrorf("Failed to change directory: %v", err) fmt.Printf("cd %s\n", ws.Path) return } if pwd, err := os.Getwd(); err == nil { - commands.PrintInfo(fmt.Sprintf("Now in: %s", pwd)) + commands.PrintInfof("Now in: %s", pwd) } fmt.Println() @@ -44,6 +44,6 @@ func NavigateToWorkspace(ws workspace.Workspace) { ) if err := cmd.Run(); err != nil { - commands.PrintError(fmt.Sprintf("Failed to start shell: %v", err)) + commands.PrintErrorf("Failed to start shell: %v", err) } } diff --git a/pkg/ui/commands/messages.go b/pkg/ui/commands/messages.go index 11594d1..b299cae 100644 --- a/pkg/ui/commands/messages.go +++ b/pkg/ui/commands/messages.go @@ -7,17 +7,41 @@ func PrintInfo(msg string) { fmt.Printf("%s %s\n", InfoStyle.Render("[INFO]"), msg) } +// PrintInfof prints a formatted informational message with blue [INFO] prefix. +func PrintInfof(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s\n", InfoStyle.Render("[INFO]"), msg) +} + // PrintSuccess prints a success message with green [SUCCESS] prefix. func PrintSuccess(msg string) { fmt.Printf("%s %s\n", SuccessStyle.Render("[SUCCESS]"), msg) } +// PrintSuccessf prints a formatted success message with green [SUCCESS] prefix. +func PrintSuccessf(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s\n", SuccessStyle.Render("[SUCCESS]"), msg) +} + // PrintError prints an error message with red [ERROR] prefix. func PrintError(msg string) { fmt.Printf("%s %s\n", ErrorStyle.Render("[ERROR]"), msg) } +// PrintErrorf prints a formatted error message with red [ERROR] prefix. +func PrintErrorf(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s\n", ErrorStyle.Render("[ERROR]"), msg) +} + // PrintWarning prints a warning message with yellow [WARNING] prefix. func PrintWarning(msg string) { fmt.Printf("%s %s\n", WarningStyle.Render("[WARNING]"), msg) } + +// PrintWarningf prints a formatted warning message with yellow [WARNING] prefix. +func PrintWarningf(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Printf("%s %s\n", WarningStyle.Render("[WARNING]"), msg) +} diff --git a/pkg/ui/commands/prompt.go b/pkg/ui/commands/prompt.go index 0480f0b..f7879f5 100644 --- a/pkg/ui/commands/prompt.go +++ b/pkg/ui/commands/prompt.go @@ -8,6 +8,7 @@ import ( ) // PromptYesNo displays a prompt and returns true if user responds y/yes. +// Defaults to no if the user just presses enter. func PromptYesNo(prompt string) bool { fmt.Print(prompt) @@ -20,3 +21,22 @@ func PromptYesNo(prompt string) bool { return response == "y" || response == "yes" } + +// PromptYesNoDefault displays a prompt with a configurable default. +// If defaultYes is true, pressing enter without input returns true. +func PromptYesNoDefault(prompt string, defaultYes bool) bool { + fmt.Print(prompt) + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return defaultYes + } + response = strings.TrimSpace(strings.ToLower(response)) + + if response == "" { + return defaultYes + } + + return response == "y" || response == "yes" +} diff --git a/pkg/ui/commands/spinner.go b/pkg/ui/commands/spinner.go new file mode 100644 index 0000000..6b985bb --- /dev/null +++ b/pkg/ui/commands/spinner.go @@ -0,0 +1,94 @@ +package commands + +import ( + "fmt" + "os" + "sync" + "time" + + "golang.org/x/term" +) + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// Spinner provides an animated spinner for long-running operations. +type Spinner struct { + message string + stop chan struct{} + done chan struct{} + mu sync.Mutex + active bool +} + +// NewSpinner creates a new spinner with the given message. +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +// Start begins the spinner animation. +func (s *Spinner) Start() { + s.mu.Lock() + if s.active { + s.mu.Unlock() + return + } + s.active = true + s.mu.Unlock() + + if !term.IsTerminal(int(os.Stdout.Fd())) { + fmt.Printf("%s %s\n", InfoStyle.Render("[INFO]"), s.message) + close(s.done) + return + } + + go func() { + defer close(s.done) + frame := 0 + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.stop: + fmt.Print("\r\033[K") + return + case <-ticker.C: + fmt.Printf("\r%s %s", InfoStyle.Render(spinnerFrames[frame]), s.message) + frame = (frame + 1) % len(spinnerFrames) + } + } + }() +} + +// Stop stops the spinner animation. +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + s.active = false + s.mu.Unlock() + + close(s.stop) + <-s.done +} + +// UpdateMessage changes the spinner message while running. +func (s *Spinner) UpdateMessage(message string) { + s.mu.Lock() + s.message = message + s.mu.Unlock() +} + +// WithSpinner runs a function while displaying a spinner. +func WithSpinner(message string, fn func()) { + spinner := NewSpinner(message) + spinner.Start() + defer spinner.Stop() + fn() +} diff --git a/pkg/ui/dashboard/dashboard.go b/pkg/ui/dashboard/dashboard.go index 044f9f7..e6b4b81 100644 --- a/pkg/ui/dashboard/dashboard.go +++ b/pkg/ui/dashboard/dashboard.go @@ -90,6 +90,15 @@ func (m DashboardModel) tickCmd() tea.Cmd { }) } +// StatusClearedMsg signals that status message should be cleared. +type StatusClearedMsg struct{} + +func (m DashboardModel) clearStatusAfterDelay() tea.Cmd { + return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return StatusClearedMsg{} + }) +} + func (m DashboardModel) loadWorkspaces() tea.Cmd { return func() tea.Msg { workspaces, err := m.workspaceManager.GetWorkspaces() @@ -219,7 +228,9 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.Refresh): - return m, m.refreshSelectedStatus() + m.refreshing = true + m.statusMessage = "Refreshing..." + return m, tea.Batch(m.refreshSelectedStatus(), m.clearStatusAfterDelay()) case key.Matches(msg, m.keys.Fetch): return m.handleFetchAction() @@ -285,6 +296,12 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMessage = msg.Error.Error() return m, nil + case StatusClearedMsg: + if m.statusMessage == "Refreshing..." { + m.statusMessage = "" + } + return m, nil + case DiffLoadedMsg: m.diffView = m.diffView.SetLoading(false) if msg.Error != nil { @@ -333,9 +350,10 @@ func (m DashboardModel) updateLayout() DashboardModel { rightWidth := m.width - leftWidth - 4 contentHeight := m.height - 6 + innerHeight := contentHeight - 2 - m.workspaceList = m.workspaceList.SetSize(leftWidth, contentHeight) - m.details = m.details.SetSize(rightWidth, contentHeight) + m.workspaceList = m.workspaceList.SetSize(leftWidth, innerHeight) + m.details = m.details.SetSize(rightWidth, innerHeight) m.help = m.help.SetSize(m.width, m.height) if m.showConfirm { m.confirmModel = m.confirmModel.SetSize(m.width, m.height) @@ -563,6 +581,7 @@ func (m DashboardModel) renderFooter() string { helpKeyStyle.Render("↑/↓ j/k") + helpDescStyle.Render(" navigate"), helpKeyStyle.Render("←/→ h/l") + helpDescStyle.Render(" panels"), helpKeyStyle.Render("Enter") + helpDescStyle.Render(" select"), + helpKeyStyle.Render("R") + helpDescStyle.Render(" refresh"), helpKeyStyle.Render("f") + helpDescStyle.Render(" fetch"), helpKeyStyle.Render("p") + helpDescStyle.Render(" pull"), helpKeyStyle.Render("d") + helpDescStyle.Render(" delete"), diff --git a/pkg/ui/dashboard/workspace_list.go b/pkg/ui/dashboard/workspace_list.go index eb78008..b21a2b5 100644 --- a/pkg/ui/dashboard/workspace_list.go +++ b/pkg/ui/dashboard/workspace_list.go @@ -13,6 +13,7 @@ type WorkspaceListModel struct { workspaces []WorkspaceData statuses map[string][]RepoStatus cursor int + scrollOffset int width int height int focused bool @@ -44,6 +45,7 @@ func (m WorkspaceListModel) SetWorkspaces(workspaces []WorkspaceData) WorkspaceL if m.cursor >= len(m.filteredIdxs) && len(m.filteredIdxs) > 0 { m.cursor = len(m.filteredIdxs) - 1 } + m.scrollOffset = 0 return m } @@ -90,6 +92,7 @@ func (m *WorkspaceListModel) resetFilter() { } m.filterText = "" m.filterMode = false + m.scrollOffset = 0 } func (m *WorkspaceListModel) applyFilter() { @@ -109,6 +112,7 @@ func (m *WorkspaceListModel) applyFilter() { if m.cursor >= len(m.filteredIdxs) && len(m.filteredIdxs) > 0 { m.cursor = len(m.filteredIdxs) - 1 } + m.scrollOffset = 0 } // Init initializes the component. @@ -150,14 +154,22 @@ func (m WorkspaceListModel) Update(msg tea.Msg) (WorkspaceListModel, tea.Cmd) { return m, nil } + maxVisible := m.maxVisibleItems() + switch { case key.Matches(keyMsg, m.keys.Up): if m.cursor > 0 { m.cursor-- + if m.cursor < m.scrollOffset { + m.scrollOffset = m.cursor + } } case key.Matches(keyMsg, m.keys.Down): if m.cursor < len(m.filteredIdxs)-1 { m.cursor++ + if m.cursor >= m.scrollOffset+maxVisible { + m.scrollOffset = m.cursor - maxVisible + 1 + } } case key.Matches(keyMsg, m.keys.Filter): m.filterMode = true @@ -167,6 +179,17 @@ func (m WorkspaceListModel) Update(msg tea.Msg) (WorkspaceListModel, tea.Cmd) { return m, nil } +func (m WorkspaceListModel) maxVisibleItems() int { + maxVisible := m.height - 4 + if m.scrollOffset > 0 { + maxVisible-- + } + if maxVisible < 1 { + maxVisible = 5 + } + return maxVisible +} + // View renders the workspace list. func (m WorkspaceListModel) View() string { var b strings.Builder @@ -185,20 +208,19 @@ func (m WorkspaceListModel) View() string { return b.String() } - maxVisible := m.height - 4 - if maxVisible < 1 { - maxVisible = 10 - } + maxVisible := m.maxVisibleItems() - start := 0 - if m.cursor >= maxVisible { - start = m.cursor - maxVisible + 1 - } + start := m.scrollOffset end := start + maxVisible if end > len(m.filteredIdxs) { end = len(m.filteredIdxs) } + if start > 0 { + b.WriteString(dimmedItemStyle.Render(fmt.Sprintf(" ... %d above", start))) + b.WriteString("\n") + } + for i := start; i < end; i++ { idx := m.filteredIdxs[i] ws := m.workspaces[idx] @@ -230,8 +252,9 @@ func (m WorkspaceListModel) View() string { b.WriteString("\n") } - if len(m.filteredIdxs) > maxVisible { - b.WriteString(dimmedItemStyle.Render(fmt.Sprintf(" ... %d more", len(m.filteredIdxs)-maxVisible))) + remaining := len(m.filteredIdxs) - end + if remaining > 0 { + b.WriteString(dimmedItemStyle.Render(fmt.Sprintf(" ... %d more", remaining))) } return b.String() diff --git a/pkg/workspace/service.go b/pkg/workspace/service.go index 19ea12e..0482fd5 100644 --- a/pkg/workspace/service.go +++ b/pkg/workspace/service.go @@ -1,9 +1,14 @@ package workspace import ( + "context" "fmt" "os" "path/filepath" + "sync" + + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" "github.com/jcleira/workspace/pkg/git" ) @@ -20,56 +25,75 @@ func NewService(manager *Manager) *Service { } } -// SyncMainRepos fetches and pulls all main repositories. +// SyncMainRepos fetches and pulls all main repositories in parallel. func (s *Service) SyncMainRepos(repos []RepositorySpec) []SyncResult { + var mu sync.Mutex results := make([]SyncResult, 0, len(repos)) + var g errgroup.Group + for _, repo := range repos { - mainRepoPath := filepath.Join(s.manager.ReposDir, repo.Name) + g.Go(func() error { + mainRepoPath := filepath.Join(s.manager.ReposDir, repo.Name) - result := SyncResult{ - RepoName: repo.Name, - RepoPath: mainRepoPath, - } + result := SyncResult{ + RepoName: repo.Name, + RepoPath: mainRepoPath, + } - if _, err := os.Stat(mainRepoPath); os.IsNotExist(err) { - continue - } + if _, err := os.Stat(mainRepoPath); os.IsNotExist(err) { + return nil + } - if err := git.FetchRemote(mainRepoPath); err != nil { - result.Error = fmt.Errorf("failed to fetch: %w", err) - results = append(results, result) - continue - } - result.Fetched = true + if err := git.FetchRemote(mainRepoPath); err != nil { + result.Error = fmt.Errorf("failed to fetch: %w", err) + mu.Lock() + results = append(results, result) + mu.Unlock() + return nil + } + result.Fetched = true + + if err := git.PullDefaultBranch(mainRepoPath); err != nil { + result.Error = fmt.Errorf("failed to pull: %w", err) + mu.Lock() + results = append(results, result) + mu.Unlock() + return nil + } + result.Pulled = true - if err := git.PullDefaultBranch(mainRepoPath); err != nil { - result.Error = fmt.Errorf("failed to pull: %w", err) + mu.Lock() results = append(results, result) - continue - } - result.Pulled = true - - results = append(results, result) + mu.Unlock() + return nil + }) } + _ = g.Wait() return results } +func (s *Service) reportProgress(input CreateInput, message string) { + if input.OnProgress != nil { + input.OnProgress(message) + } +} + // Create creates a new workspace with repositories. -func (s *Service) Create(input CreateInput) (*CreateOutput, error) { +func (s *Service) Create(input CreateInput) (CreateOutput, error) { repos, err := DiscoverMainRepos(s.manager.ReposDir) if err != nil { - return nil, fmt.Errorf("failed to discover repositories: %w", err) + return CreateOutput{}, fmt.Errorf("failed to discover repositories: %w", err) } if len(repos) == 0 { - return nil, fmt.Errorf("no repositories found in %s", s.manager.ReposDir) + return CreateOutput{}, fmt.Errorf("no repositories found in %s", s.manager.ReposDir) } useWorktrees := input.Name != "default" - output := &CreateOutput{ + output := CreateOutput{ WorkspaceType: WorkspaceTypeClone, CreatedRepos: make([]RepoResult, 0), FailedRepos: make([]RepoResult, 0), @@ -78,38 +102,59 @@ func (s *Service) Create(input CreateInput) (*CreateOutput, error) { if useWorktrees { output.WorkspaceType = WorkspaceTypeWorktree + s.reportProgress(input, "Syncing main repositories...") output.SyncResults = s.SyncMainRepos(repos) } + s.reportProgress(input, "Creating workspace directory...") workspacePath, alreadyExists, err := s.createWorkspaceDir(input.Name) if err != nil { - return nil, err + return CreateOutput{}, err } output.WorkspacePath = workspacePath output.AlreadyExists = alreadyExists - for _, repo := range repos { - targetPath := filepath.Join(workspacePath, repo.Name) + var mu sync.Mutex + sem := semaphore.NewWeighted(3) + ctx := context.Background() - if useWorktrees { - result := s.createWorktree(repo, targetPath, input.Name) - if result.Error != nil { - output.FailedRepos = append(output.FailedRepos, result) - } else { - output.CreatedRepos = append(output.CreatedRepos, result) - } - } else { - result := s.cloneRepo(repo, workspacePath) - if result.Error != nil { - output.FailedRepos = append(output.FailedRepos, result) + var wg sync.WaitGroup + for _, repo := range repos { + wg.Add(1) + go func() { + defer wg.Done() + _ = sem.Acquire(ctx, 1) + defer sem.Release(1) + + targetPath := filepath.Join(workspacePath, repo.Name) + + if useWorktrees { + s.reportProgress(input, fmt.Sprintf("Creating worktree for %s...", repo.Name)) + result := s.createWorktree(repo, targetPath, input.Name) + mu.Lock() + if result.Error != nil { + output.FailedRepos = append(output.FailedRepos, result) + } else { + output.CreatedRepos = append(output.CreatedRepos, result) + } + mu.Unlock() } else { - output.CreatedRepos = append(output.CreatedRepos, result) + s.reportProgress(input, fmt.Sprintf("Cloning %s...", repo.Name)) + result := s.cloneRepo(repo, workspacePath) + mu.Lock() + if result.Error != nil { + output.FailedRepos = append(output.FailedRepos, result) + } else { + output.CreatedRepos = append(output.CreatedRepos, result) + } + mu.Unlock() } - } + }() } + wg.Wait() if err := CreateWorkspaceInfo(workspacePath, input.Name, "", len(output.CreatedRepos), len(output.FailedRepos)); err != nil { - return nil, fmt.Errorf("failed to write workspace info: %w", err) + return CreateOutput{}, fmt.Errorf("failed to write workspace info: %w", err) } return output, nil @@ -197,18 +242,18 @@ func (s *Service) cloneRepo(repo RepositorySpec, workspacePath string) RepoResul } // Delete removes a workspace and optionally its associated branches. -func (s *Service) Delete(input DeleteInput) (*DeleteOutput, error) { +func (s *Service) Delete(input DeleteInput) (DeleteOutput, error) { if input.Name == "default" { - return nil, fmt.Errorf("the 'default' workspace is protected and cannot be deleted") + return DeleteOutput{}, fmt.Errorf("the 'default' workspace is protected and cannot be deleted") } workspacePath := filepath.Join(s.manager.WorkspacesDir, "workspace-"+input.Name) if _, err := os.Stat(workspacePath); os.IsNotExist(err) { - return nil, fmt.Errorf("workspace 'workspace-%s' does not exist", input.Name) + return DeleteOutput{}, fmt.Errorf("workspace 'workspace-%s' does not exist", input.Name) } - output := &DeleteOutput{ + output := DeleteOutput{ WorkspacePath: workspacePath, DeletedBranches: make([]BranchResult, 0), SkippedBranches: make([]BranchResult, 0), @@ -220,21 +265,23 @@ func (s *Service) Delete(input DeleteInput) (*DeleteOutput, error) { } if wsType == WorkspaceTypeWorktree { - s.cleanupWorktrees(workspacePath, input.DeleteBranches, output) + deleted, skipped := s.cleanupWorktrees(workspacePath, input.DeleteBranches) + output.DeletedBranches = deleted + output.SkippedBranches = skipped } if err := os.RemoveAll(workspacePath); err != nil { - return nil, fmt.Errorf("failed to delete workspace: %w", err) + return DeleteOutput{}, fmt.Errorf("failed to delete workspace: %w", err) } output.Deleted = true return output, nil } -func (s *Service) cleanupWorktrees(workspacePath string, deleteBranches bool, output *DeleteOutput) { +func (s *Service) cleanupWorktrees(workspacePath string, deleteBranches bool) (deleted, skipped []BranchResult) { entries, err := os.ReadDir(workspacePath) if err != nil { - return + return nil, nil } for _, entry := range entries { @@ -273,12 +320,14 @@ func (s *Service) cleanupWorktrees(workspacePath string, deleteBranches bool, ou if err := git.DeleteBranch(mainRepoPath, branchName); err != nil { result.Error = err - output.SkippedBranches = append(output.SkippedBranches, result) + skipped = append(skipped, result) } else { - output.DeletedBranches = append(output.DeletedBranches, result) + deleted = append(deleted, result) } } } + + return deleted, skipped } // List returns all workspaces. diff --git a/pkg/workspace/service_test.go b/pkg/workspace/service_test.go new file mode 100644 index 0000000..ba49d3c --- /dev/null +++ b/pkg/workspace/service_test.go @@ -0,0 +1,306 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewService(t *testing.T) { + t.Parallel() + + manager := NewManager("/tmp/workspaces", "/tmp/repos", "/tmp/claude") + svc := NewService(manager) + + if svc == nil { + t.Fatal("expected non-nil service") + } + if svc.manager != manager { + t.Error("expected service to use provided manager") + } +} + +func TestService_Create(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input CreateInput + setupFunc func(t *testing.T, workspacesDir, reposDir string) + wantErr bool + checkFunc func(t *testing.T, output CreateOutput, err error) + }{ + { + name: "creates workspace with no repos returns error", + input: CreateInput{ + Name: "test-workspace", + }, + setupFunc: func(t *testing.T, workspacesDir, reposDir string) { + if err := os.MkdirAll(reposDir, 0o755); err != nil { + t.Fatal(err) + } + }, + wantErr: true, + checkFunc: func(t *testing.T, output CreateOutput, err error) { + if err == nil { + t.Error("expected error for empty repos directory") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if tt.setupFunc != nil { + tt.setupFunc(t, workspacesDir, reposDir) + } + + manager := NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(manager) + + output, err := svc.Create(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.checkFunc != nil { + tt.checkFunc(t, output, err) + } + }) + } +} + +func TestService_Delete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input DeleteInput + setupFunc func(t *testing.T, workspacesDir string) + wantErr bool + checkFunc func(t *testing.T, output DeleteOutput, err error) + }{ + { + name: "delete default workspace returns error", + input: DeleteInput{ + Name: "default", + DeleteBranches: true, + }, + wantErr: true, + checkFunc: func(t *testing.T, output DeleteOutput, err error) { + if err == nil { + t.Error("expected error when deleting default workspace") + } + }, + }, + { + name: "delete non-existent workspace returns error", + input: DeleteInput{ + Name: "nonexistent", + DeleteBranches: true, + }, + wantErr: true, + checkFunc: func(t *testing.T, output DeleteOutput, err error) { + if err == nil { + t.Error("expected error for non-existent workspace") + } + }, + }, + { + name: "delete existing workspace succeeds", + input: DeleteInput{ + Name: "test-delete", + DeleteBranches: false, + }, + setupFunc: func(t *testing.T, workspacesDir string) { + wsPath := filepath.Join(workspacesDir, "workspace-test-delete") + if err := os.MkdirAll(wsPath, 0o755); err != nil { + t.Fatal(err) + } + }, + wantErr: false, + checkFunc: func(t *testing.T, output DeleteOutput, err error) { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !output.Deleted { + t.Error("expected Deleted to be true") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + t.Fatal(err) + } + + if tt.setupFunc != nil { + tt.setupFunc(t, workspacesDir) + } + + manager := NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(manager) + + output, err := svc.Delete(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("Delete() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.checkFunc != nil { + tt.checkFunc(t, output, err) + } + }) + } +} + +func TestService_List(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, workspacesDir string) + wantCount int + wantErr bool + }{ + { + name: "empty workspaces directory returns empty list", + wantCount: 0, + wantErr: false, + }, + { + name: "returns existing workspaces", + setupFunc: func(t *testing.T, workspacesDir string) { + if err := os.MkdirAll(filepath.Join(workspacesDir, "workspace-test1"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(workspacesDir, "workspace-test2"), 0o755); err != nil { + t.Fatal(err) + } + }, + wantCount: 2, + wantErr: false, + }, + { + name: "ignores non-workspace directories", + setupFunc: func(t *testing.T, workspacesDir string) { + if err := os.MkdirAll(filepath.Join(workspacesDir, "workspace-valid"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(workspacesDir, "not-a-workspace"), 0o755); err != nil { + t.Fatal(err) + } + }, + wantCount: 1, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + t.Fatal(err) + } + + if tt.setupFunc != nil { + tt.setupFunc(t, workspacesDir) + } + + manager := NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(manager) + + infos, err := svc.List() + + if (err != nil) != tt.wantErr { + t.Errorf("List() error = %v, wantErr %v", err, tt.wantErr) + } + + if len(infos) != tt.wantCount { + t.Errorf("List() returned %d workspaces, want %d", len(infos), tt.wantCount) + } + }) + } +} + +func TestService_GetPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wsName string + setupFunc func(t *testing.T, workspacesDir string) + wantErr bool + }{ + { + name: "non-existent workspace returns error", + wsName: "nonexistent", + wantErr: true, + }, + { + name: "existing workspace returns path", + wsName: "myworkspace", + setupFunc: func(t *testing.T, workspacesDir string) { + if err := os.MkdirAll(filepath.Join(workspacesDir, "workspace-myworkspace"), 0o755); err != nil { + t.Fatal(err) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + workspacesDir := filepath.Join(tmpDir, "workspaces") + reposDir := filepath.Join(tmpDir, "repos") + claudeDir := filepath.Join(tmpDir, ".claude") + + if err := os.MkdirAll(workspacesDir, 0o755); err != nil { + t.Fatal(err) + } + + if tt.setupFunc != nil { + tt.setupFunc(t, workspacesDir) + } + + manager := NewManager(workspacesDir, reposDir, claudeDir) + svc := NewService(manager) + + path, err := svc.GetPath(tt.wsName) + + if (err != nil) != tt.wantErr { + t.Errorf("GetPath() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && path == "" { + t.Error("GetPath() returned empty path") + } + }) + } +} diff --git a/pkg/workspace/types.go b/pkg/workspace/types.go index 06cd368..b4dc469 100644 --- a/pkg/workspace/types.go +++ b/pkg/workspace/types.go @@ -2,9 +2,13 @@ package workspace import "time" +// ProgressCallback is called to report progress during operations. +type ProgressCallback func(message string) + // CreateInput contains all parameters for creating a workspace. type CreateInput struct { - Name string + Name string + OnProgress ProgressCallback } // CreateOutput contains the result of workspace creation.