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.