diff --git a/cmd/down.go b/cmd/down.go index cd667fc..ecb14e9 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -9,6 +9,7 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -57,9 +58,9 @@ func runDown(gitClient git.GitClient) error { targetBranch = children[0].Name } else { // Multiple children, prompt for selection - fmt.Printf("Multiple children found for %s:\n", currentBranch) + fmt.Printf("Multiple children found for %s:\n", ui.Branch(currentBranch)) for i, child := range children { - fmt.Printf(" %d) %s\n", i+1, child.Name) + fmt.Printf(" %d) %s\n", i+1, ui.Branch(child.Name)) } fmt.Print("\nSelect branch (1-" + strconv.Itoa(len(children)) + "): ") @@ -83,6 +84,6 @@ func runDown(gitClient git.GitClient) error { return fmt.Errorf("failed to checkout child branch %s: %w", targetBranch, err) } - fmt.Printf("Switched to child branch: %s\n", targetBranch) + fmt.Printf("Switched to child branch: %s\n", ui.Branch(targetBranch)) return nil } diff --git a/cmd/new.go b/cmd/new.go index 759286a..51aef07 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -6,6 +6,7 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -77,7 +78,7 @@ func runNew(gitClient git.GitClient, branchName string, explicitParent string) e } } - fmt.Printf("Creating new branch %s from %s\n", branchName, parent) + fmt.Printf("Creating new branch %s from %s\n", ui.Branch(branchName), ui.Branch(parent)) // Create the new branch if err := gitClient.CreateBranchAndCheckout(branchName, parent); err != nil { @@ -91,7 +92,7 @@ func runNew(gitClient git.GitClient, branchName string, explicitParent string) e } if !dryRun { - fmt.Printf("✓ Created branch %s with parent %s\n", branchName, parent) + fmt.Println(ui.Success(fmt.Sprintf("Created branch %s with parent %s", ui.Branch(branchName), ui.Branch(parent)))) fmt.Println() // Show the local stack (fast, no PR fetching) diff --git a/cmd/parent.go b/cmd/parent.go index e683fbf..d2d7694 100644 --- a/cmd/parent.go +++ b/cmd/parent.go @@ -5,6 +5,7 @@ import ( "os" "github.com/javoire/stackinator/internal/git" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -38,9 +39,9 @@ func runParent(gitClient git.GitClient) error { parent := gitClient.GetConfig(fmt.Sprintf("branch.%s.stackparent", currentBranch)) if parent == "" { - fmt.Printf("%s (not in a stack)\n", currentBranch) + fmt.Printf("%s %s\n", ui.Branch(currentBranch), ui.Dim("(not in a stack)")) } else { - fmt.Println(parent) + fmt.Println(ui.Branch(parent)) } return nil diff --git a/cmd/prune.go b/cmd/prune.go index 7dca5aa..ce42d25 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -9,6 +9,7 @@ import ( "github.com/javoire/stackinator/internal/github" "github.com/javoire/stackinator/internal/spinner" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -154,7 +155,7 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error { fmt.Printf("Found %d merged branch(es) to prune:\n", len(mergedBranches)) for _, branch := range mergedBranches { pr := prCache[branch] - fmt.Printf(" - %s (PR #%d)\n", branch, pr.Number) + fmt.Printf(" - %s (PR #%d)\n", ui.Branch(branch), pr.Number) } fmt.Println() @@ -165,7 +166,7 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error { // Prune each merged branch for i, branch := range mergedBranches { - fmt.Printf("(%d/%d) Pruning %s...\n", i+1, len(mergedBranches), branch) + fmt.Printf("%s Pruning %s...\n", ui.Progress(i+1, len(mergedBranches)), ui.Branch(branch)) // Remove from stack tracking (if in stack) configKey := fmt.Sprintf("branch.%s.stackparent", branch) @@ -178,7 +179,7 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error { // Don't delete current branch if branch == currentBranch { - fmt.Println(" ⚠ Skipping deletion (currently checked out)") + fmt.Printf(" %s Skipping deletion (currently checked out)\n", ui.WarningIcon()) fmt.Println() continue } @@ -195,15 +196,16 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error { if deleteErr != nil { fmt.Fprintf(os.Stderr, " Warning: failed to delete branch: %v\n", deleteErr) if !pruneForce { - fmt.Fprintf(os.Stderr, " Use 'stack prune --force' to force delete, or manually delete with: git branch -D %s\n", branch) + fmt.Fprintf(os.Stderr, " Use '%s' to force delete, or manually delete with: %s\n", + ui.Command("stack prune --force"), ui.Command(fmt.Sprintf("git branch -D %s", branch))) } } else { - fmt.Println(" ✓ Deleted") + fmt.Printf(" %s Deleted\n", ui.SuccessIcon()) } fmt.Println() } - fmt.Println("✓ Prune complete!") + fmt.Println(ui.Success("Prune complete!")) return nil } diff --git a/cmd/rename.go b/cmd/rename.go index 40bf528..fc3108a 100644 --- a/cmd/rename.go +++ b/cmd/rename.go @@ -6,6 +6,7 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -62,7 +63,7 @@ func runRename(gitClient git.GitClient, newName string) error { return fmt.Errorf("failed to get children: %w", err) } - fmt.Printf("Renaming branch %s -> %s\n", oldName, newName) + fmt.Printf("Renaming branch %s -> %s\n", ui.Branch(oldName), ui.Branch(newName)) if len(children) > 0 { fmt.Printf(" Will update %d child branch(es)\n", len(children)) } @@ -94,11 +95,11 @@ func runRename(gitClient git.GitClient, newName string) error { if err := gitClient.SetConfig(childConfigKey, newName); err != nil { return fmt.Errorf("failed to update child %s: %w", child.Name, err) } - fmt.Printf(" ✓ Updated child %s to point to %s\n", child.Name, newName) + fmt.Printf(" %s Updated child %s to point to %s\n", ui.SuccessIcon(), ui.Branch(child.Name), ui.Branch(newName)) } if !dryRun { - fmt.Printf("✓ Successfully renamed branch %s -> %s\n", oldName, newName) + fmt.Println(ui.Success(fmt.Sprintf("Successfully renamed branch %s -> %s", ui.Branch(oldName), ui.Branch(newName)))) fmt.Println() // Show the updated stack (local only, fast) diff --git a/cmd/reparent.go b/cmd/reparent.go index b8e8d5a..23a82a3 100644 --- a/cmd/reparent.go +++ b/cmd/reparent.go @@ -6,6 +6,7 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/github" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -56,7 +57,7 @@ func runReparent(gitClient git.GitClient, githubClient github.GitHubClient, newP // Check if new parent is the same as current parent if currentParent != "" && newParent == currentParent { - fmt.Printf("Branch %s is already parented to %s\n", currentBranch, newParent) + fmt.Printf("Branch %s is already parented to %s\n", ui.Branch(currentBranch), ui.Branch(newParent)) return nil } @@ -77,9 +78,9 @@ func runReparent(gitClient git.GitClient, githubClient github.GitHubClient, newP // Print appropriate message based on whether we're adding to stack or reparenting if currentParent == "" { - fmt.Printf("Adding %s to stack with parent %s\n", currentBranch, newParent) + fmt.Printf("Adding %s to stack with parent %s\n", ui.Branch(currentBranch), ui.Branch(newParent)) } else { - fmt.Printf("Reparenting %s: %s -> %s\n", currentBranch, currentParent, newParent) + fmt.Printf("Reparenting %s: %s -> %s\n", ui.Branch(currentBranch), ui.Branch(currentParent), ui.Branch(newParent)) } // Update git config @@ -92,29 +93,29 @@ func runReparent(gitClient git.GitClient, githubClient github.GitHubClient, newP pr, err := githubClient.GetPRForBranch(currentBranch) if err != nil { // Error fetching PR info, but config was updated successfully - fmt.Printf("✓ Updated parent to %s\n", newParent) + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) fmt.Printf("Warning: failed to check for PR: %v\n", err) return nil } if pr != nil { // PR exists, update its base - fmt.Printf("Updating PR #%d base: %s -> %s\n", pr.Number, pr.Base, newParent) + fmt.Printf("Updating PR #%d base: %s -> %s\n", pr.Number, ui.Branch(pr.Base), ui.Branch(newParent)) if err := githubClient.UpdatePRBase(pr.Number, newParent); err != nil { // Config was updated but PR base update failed - fmt.Printf("✓ Updated parent to %s\n", newParent) + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) return fmt.Errorf("failed to update PR base: %w", err) } if !dryRun { - fmt.Printf("✓ Updated parent to %s\n", newParent) - fmt.Printf("✓ Updated PR #%d base to %s\n", pr.Number, newParent) + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) + fmt.Println(ui.Success(fmt.Sprintf("Updated PR #%d base to %s", pr.Number, ui.Branch(newParent)))) } } else { // No PR exists if !dryRun { - fmt.Printf("✓ Updated parent to %s\n", newParent) + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) fmt.Println(" (no PR found for this branch)") } } diff --git a/cmd/root.go b/cmd/root.go index e369529..350c99b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,12 +7,14 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/github" "github.com/javoire/stackinator/internal/spinner" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) var ( dryRun bool verbose bool + noColor bool ) var rootCmd = &cobra.Command{ @@ -49,6 +51,9 @@ The tool helps you create, navigate, and sync stacked branches with minimal over // Disable spinners in verbose mode to avoid visual conflicts spinner.Enabled = !verbose + // Set color output flag + ui.SetNoColor(noColor) + // Validate we're in a git repository gitClient := git.NewGitClient() if _, err := gitClient.GetRepoRoot(); err != nil { @@ -61,6 +66,7 @@ The tool helps you create, navigate, and sync stacked branches with minimal over func init() { rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would happen without executing") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output") + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output") // Add subcommands rootCmd.AddCommand(newCmd) diff --git a/cmd/show.go b/cmd/show.go index fa8097a..456c783 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -6,6 +6,7 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -49,8 +50,8 @@ func runShow(gitClient git.GitClient) error { if len(stackBranches) == 0 { fmt.Println("No stack branches found.") - fmt.Printf("Current branch: %s\n", currentBranch) - fmt.Println("\nUse 'stack new ' to create a new stack branch.") + fmt.Printf("Current branch: %s\n", ui.Branch(currentBranch)) + fmt.Printf("\nUse '%s' to create a new stack branch.\n", ui.Command("stack new ")) return nil } @@ -75,16 +76,16 @@ func printLocalStackTree(node *stack.TreeNode, currentBranch string, isPipe bool marker := "" if node.Name == currentBranch { - marker = " *" + marker = ui.CurrentBranchMarker() } // Print pipe if needed if isPipe { - fmt.Println(" |") + fmt.Printf(" %s\n", ui.Pipe()) } // Print current node (no PR info) - fmt.Printf(" %s%s\n", node.Name, marker) + fmt.Printf(" %s%s\n", ui.Branch(node.Name), marker) // Print children vertically for _, child := range node.Children { diff --git a/cmd/status.go b/cmd/status.go index 86b389f..e81b31e 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -12,6 +12,7 @@ import ( "github.com/javoire/stackinator/internal/github" "github.com/javoire/stackinator/internal/spinner" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -133,8 +134,8 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error // Wait for PR fetch to complete before returning wg.Wait() fmt.Println("No stack branches found.") - fmt.Printf("Current branch: %s\n", currentBranch) - fmt.Println("\nUse 'stack new ' to create a new stack branch.") + fmt.Printf("Current branch: %s\n", ui.Branch(currentBranch)) + fmt.Printf("\nUse '%s' to create a new stack branch.\n", ui.Command("stack new ")) return nil } @@ -142,8 +143,8 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error // Check this BEFORE waiting for PR fetch to avoid long delays if tree == nil { baseBranch := stack.GetBaseBranch(gitClient) - fmt.Printf("Current branch '%s' is not part of a stack.\n\n", currentBranch) - fmt.Printf("Add to stack with '%s' as parent? [Y/n] ", baseBranch) + fmt.Printf("Current branch '%s' is not part of a stack.\n\n", ui.Branch(currentBranch)) + fmt.Printf("Add to stack with '%s' as parent? [Y/n] ", ui.Branch(baseBranch)) reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') @@ -158,7 +159,8 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error if err := gitClient.SetConfig(configKey, baseBranch); err != nil { return fmt.Errorf("failed to set stack parent: %w", err) } - fmt.Printf("✓ Added '%s' to stack with parent '%s'\n\n", currentBranch, baseBranch) + fmt.Println(ui.Success(fmt.Sprintf("Added '%s' to stack with parent '%s'", ui.Branch(currentBranch), ui.Branch(baseBranch)))) + fmt.Println() // Run status again to show the stack return runStatus(gitClient, githubClient) } @@ -270,24 +272,24 @@ func printTreeVertical(gitClient git.GitClient, node *stack.TreeNode, currentBra // Determine the current branch marker marker := "" if node.Name == currentBranch { - marker = " *" + marker = ui.CurrentBranchMarker() } // Get PR info from cache prInfo := "" if node.Name != stack.GetBaseBranch(gitClient) { if pr, exists := prCache[node.Name]; exists { - prInfo = fmt.Sprintf(" [%s :%s]", pr.URL, strings.ToLower(pr.State)) + prInfo = fmt.Sprintf(" %s", ui.PRInfo(pr.URL, pr.State)) } } // Print pipe if needed if isPipe { - fmt.Println(" |") + fmt.Printf(" %s\n", ui.Pipe()) } // Print current node - fmt.Printf(" %s%s%s\n", node.Name, prInfo, marker) + fmt.Printf(" %s%s%s\n", ui.Branch(node.Name), prInfo, marker) // Print children vertically for _, child := range node.Children { @@ -404,14 +406,14 @@ func detectSyncIssues(gitClient git.GitClient, stackBranches []stack.StackBranch func printSyncIssues(result *syncIssuesResult) { if len(result.issues) > 0 { fmt.Println() - fmt.Println("⚠ Stack out of sync detected:") + fmt.Println(ui.Warning("Stack out of sync detected:")) for _, issue := range result.issues { fmt.Println(issue) } fmt.Println() - fmt.Println("Run 'stack sync' to rebase branches and update PR bases.") + fmt.Printf("Run '%s' to rebase branches and update PR bases.\n", ui.Command("stack sync")) } else { fmt.Println() - fmt.Println("✓ Stack is perfectly synced! All branches are up to date.") + fmt.Println(ui.Success("Stack is perfectly synced! All branches are up to date.")) } } diff --git a/cmd/sync.go b/cmd/sync.go index eb56c66..43eeb0a 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -13,6 +13,7 @@ import ( "github.com/javoire/stackinator/internal/github" "github.com/javoire/stackinator/internal/spinner" "github.com/javoire/stackinator/internal/stack" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -120,7 +121,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { if err := gitClient.AbortCherryPick(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to abort cherry-pick: %v\n", err) } else { - fmt.Println("✓ Aborted cherry-pick") + fmt.Println(ui.Success("Aborted cherry-pick")) } } else if git.Verbose { fmt.Fprintf(os.Stderr, "Note: no cherry-pick in progress\n") @@ -131,7 +132,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { if err := gitClient.AbortRebase(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to abort rebase: %v\n", err) } else { - fmt.Println("✓ Aborted rebase") + fmt.Println(ui.Success("Aborted rebase")) } } else if git.Verbose { fmt.Fprintf(os.Stderr, "Note: no rebase in progress\n") @@ -142,9 +143,9 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { fmt.Println("Restoring stashed changes...") if err := gitClient.StashPop(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to restore stashed changes: %v\n", err) - fmt.Fprintf(os.Stderr, "Run 'git stash pop' manually to restore your changes\n") + fmt.Fprintf(os.Stderr, "Run '%s' manually to restore your changes\n", ui.Command("git stash pop")) } else { - fmt.Println("✓ Restored stashed changes") + fmt.Println(ui.Success("Restored stashed changes")) } } @@ -152,11 +153,11 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { if savedOriginalBranch != "" { currentBranch, err := gitClient.GetCurrentBranch() if err == nil && currentBranch != savedOriginalBranch { - fmt.Printf("Returning to %s...\n", savedOriginalBranch) + fmt.Printf("Returning to %s...\n", ui.Branch(savedOriginalBranch)) if err := gitClient.CheckoutBranch(savedOriginalBranch); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to return to original branch: %v\n", err) } else { - fmt.Printf("✓ Returned to %s\n", savedOriginalBranch) + fmt.Println(ui.Success(fmt.Sprintf("Returned to %s", ui.Branch(savedOriginalBranch)))) } } } @@ -166,7 +167,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { _ = gitClient.UnsetConfig(configSyncOriginalBranch) fmt.Println() - fmt.Println("✓ Sync aborted and state cleaned up") + fmt.Println(ui.Success("Sync aborted and state cleaned up")) return nil } @@ -263,8 +264,8 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { parent := gitClient.GetConfig(fmt.Sprintf("branch.%s.stackparent", originalBranch)) if parent == "" && originalBranch != baseBranch { - fmt.Printf("Branch '%s' is not in a stack.\n", originalBranch) - fmt.Printf("Add it with parent '%s'? [Y/n] ", baseBranch) + fmt.Printf("Branch '%s' is not in a stack.\n", ui.Branch(originalBranch)) + fmt.Printf("Add it with parent '%s'? [Y/n] ", ui.Branch(baseBranch)) reader := bufio.NewReader(stdinReader) input, err := reader.ReadString('\n') @@ -283,7 +284,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { if err := gitClient.SetConfig(configKey, baseBranch); err != nil { return fmt.Errorf("failed to set parent: %w", err) } - fmt.Printf("✓ Added '%s' to stack with parent '%s'\n", originalBranch, baseBranch) + fmt.Println(ui.Success(fmt.Sprintf("Added '%s' to stack with parent '%s'", ui.Branch(originalBranch), ui.Branch(baseBranch)))) } // Start parallel fetch operations (git fetch and GitHub PR fetch) @@ -468,23 +469,23 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { // Process each branch for i, branch := range sorted { - progress := fmt.Sprintf("(%d/%d)", i+1, len(sorted)) + progress := ui.Progress(i+1, len(sorted)) // Check if this branch has a merged PR - if so, remove from stack tracking if pr, exists := prCache[branch.Name]; exists && pr.State == "MERGED" { - fmt.Printf("%s Skipping %s (PR #%d is merged)...\n", progress, branch.Name, pr.Number) + fmt.Printf("%s Skipping %s (PR #%d is %s)...\n", progress, ui.Branch(branch.Name), pr.Number, ui.PRState(pr.State)) fmt.Printf(" Removing from stack tracking...\n") configKey := fmt.Sprintf("branch.%s.stackparent", branch.Name) if err := gitClient.UnsetConfig(configKey); err != nil { fmt.Fprintf(os.Stderr, " Warning: failed to remove stack config: %v\n", err) } else { - fmt.Printf(" ✓ Removed. You can delete this branch with: git branch -d %s\n", branch.Name) + fmt.Printf(" %s Removed. You can delete this branch with: %s\n", ui.SuccessIcon(), ui.Command(fmt.Sprintf("git branch -d %s", branch.Name))) } fmt.Println() continue } - fmt.Printf("%s Processing %s...\n", progress, branch.Name) + fmt.Printf("%s Processing %s...\n", progress, ui.Branch(branch.Name)) // Check if parent PR is merged oldParent := "" // Track old parent for --onto rebase @@ -501,7 +502,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { grandparent = stack.GetBaseBranch(gitClient) } - fmt.Printf(" ✓ Updated parent from %s to %s\n", branch.Parent, grandparent) + fmt.Printf(" %s Updated parent from %s to %s\n", ui.SuccessIcon(), ui.Branch(branch.Parent), ui.Branch(grandparent)) configKey := fmt.Sprintf("branch.%s.stackparent", branch.Name) if err := gitClient.SetConfig(configKey, grandparent); err != nil { fmt.Fprintf(os.Stderr, " Warning: failed to update parent config: %v\n", err) @@ -731,8 +732,8 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { return fmt.Errorf("failed to restore stackparent config: %w", err) } - fmt.Printf(" ✓ Rebuilt %s (backup saved as %s)\n", branch.Name, backupBranch) - fmt.Printf(" To delete backup later: git branch -D %s\n", backupBranch) + fmt.Printf(" %s Rebuilt %s (backup saved as %s)\n", ui.SuccessIcon(), ui.Branch(branch.Name), ui.Branch(backupBranch)) + fmt.Printf(" To delete backup later: %s\n", ui.Command(fmt.Sprintf("git branch -D %s", backupBranch))) // Branch is now clean - no need to rebase, just return nil return nil @@ -847,24 +848,24 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { pr := prCache[branch.Name] if pr != nil { if pr.Base != branch.Parent { - fmt.Printf(" Updating PR #%d base from %s to %s...\n", pr.Number, pr.Base, branch.Parent) + fmt.Printf(" Updating PR #%d base from %s to %s...\n", pr.Number, ui.Branch(pr.Base), ui.Branch(branch.Parent)) if err := githubClient.UpdatePRBase(pr.Number, branch.Parent); err != nil { fmt.Fprintf(os.Stderr, " Warning: failed to update PR base: %v\n", err) } else { - fmt.Printf(" ✓ PR #%d updated\n", pr.Number) + fmt.Printf(" %s PR #%d updated\n", ui.SuccessIcon(), pr.Number) } } else { - fmt.Printf(" ✓ PR #%d base is already correct (%s)\n", pr.Number, pr.Base) + fmt.Printf(" %s PR #%d base is already correct (%s)\n", ui.SuccessIcon(), pr.Number, ui.Branch(pr.Base)) } } else { - fmt.Printf(" No PR found (create one with 'gh pr create')\n") + fmt.Printf(" No PR found (create one with '%s')\n", ui.Command("gh pr create")) } fmt.Println() } // Return to original branch - fmt.Printf("Returning to %s...\n", originalBranch) + fmt.Printf("Returning to %s...\n", ui.Branch(originalBranch)) if err := gitClient.CheckoutBranch(originalBranch); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to return to original branch: %v\n", err) } @@ -895,7 +896,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { _ = gitClient.UnsetConfig(configSyncOriginalBranch) fmt.Println() - fmt.Println("✓ Sync complete!") + fmt.Println(ui.Success("Sync complete!")) return nil } @@ -970,24 +971,24 @@ func printTreeVerticalForSync(gitClient git.GitClient, node *stack.TreeNode, cur // Determine the current branch marker marker := "" if node.Name == currentBranch { - marker = " *" + marker = ui.CurrentBranchMarker() } // Get PR info from cache prInfo := "" if node.Name != stack.GetBaseBranch(gitClient) { if pr, exists := prCache[node.Name]; exists { - prInfo = fmt.Sprintf(" [%s :%s]", pr.URL, strings.ToLower(pr.State)) + prInfo = fmt.Sprintf(" %s", ui.PRInfo(pr.URL, pr.State)) } } // Print pipe if needed if isPipe { - fmt.Println(" |") + fmt.Printf(" %s\n", ui.Pipe()) } // Print current node - fmt.Printf(" %s%s%s\n", node.Name, prInfo, marker) + fmt.Printf(" %s%s%s\n", ui.Branch(node.Name), prInfo, marker) // Print children vertically for _, child := range node.Children { diff --git a/cmd/up.go b/cmd/up.go index 5fa27a3..5aa44f8 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -5,6 +5,7 @@ import ( "os" "github.com/javoire/stackinator/internal/git" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -46,6 +47,6 @@ func runUp(gitClient git.GitClient) error { return fmt.Errorf("failed to checkout parent branch %s: %w", parent, err) } - fmt.Printf("Switched to parent branch: %s\n", parent) + fmt.Printf("Switched to parent branch: %s\n", ui.Branch(parent)) return nil } diff --git a/cmd/worktree.go b/cmd/worktree.go index d5743c1..d18e300 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -10,6 +10,7 @@ import ( "github.com/javoire/stackinator/internal/git" "github.com/javoire/stackinator/internal/github" "github.com/javoire/stackinator/internal/spinner" + "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) @@ -122,7 +123,7 @@ func createNewBranchWorktree(gitClient git.GitClient, branchName, baseBranch, wo baseRef = "origin/" + baseBranch } - fmt.Printf("Creating new branch %s from %s\n", branchName, baseRef) + fmt.Printf("Creating new branch %s from %s\n", ui.Branch(branchName), ui.Branch(baseRef)) // Create worktree with new branch if err := gitClient.AddWorktreeNewBranch(worktreePath, branchName, baseRef); err != nil { @@ -136,9 +137,9 @@ func createNewBranchWorktree(gitClient git.GitClient, branchName, baseBranch, wo } if !dryRun { - fmt.Printf("✓ Created worktree at %s\n", worktreePath) - fmt.Printf("✓ Branch %s with parent %s\n", branchName, baseBranch) - fmt.Printf("\nTo switch to this worktree, run:\n cd %s\n", worktreePath) + fmt.Println(ui.Success(fmt.Sprintf("Created worktree at %s", worktreePath))) + fmt.Println(ui.Success(fmt.Sprintf("Branch %s with parent %s", ui.Branch(branchName), ui.Branch(baseBranch)))) + fmt.Printf("\nTo switch to this worktree, run:\n %s\n", ui.Command(fmt.Sprintf("cd %s", worktreePath))) } return nil @@ -147,26 +148,26 @@ func createNewBranchWorktree(gitClient git.GitClient, branchName, baseBranch, wo func createWorktreeForExisting(gitClient git.GitClient, branchName, worktreePath string) error { // Check if branch exists locally if gitClient.BranchExists(branchName) { - fmt.Printf("Creating worktree for local branch %s\n", branchName) + fmt.Printf("Creating worktree for local branch %s\n", ui.Branch(branchName)) if err := gitClient.AddWorktree(worktreePath, branchName); err != nil { return fmt.Errorf("failed to create worktree: %w", err) } if !dryRun { - fmt.Printf("✓ Created worktree at %s\n", worktreePath) - fmt.Printf("\nTo switch to this worktree, run:\n cd %s\n", worktreePath) + fmt.Println(ui.Success(fmt.Sprintf("Created worktree at %s", worktreePath))) + fmt.Printf("\nTo switch to this worktree, run:\n %s\n", ui.Command(fmt.Sprintf("cd %s", worktreePath))) } return nil } // Check if branch exists on remote if gitClient.RemoteBranchExists(branchName) { - fmt.Printf("Creating worktree for remote branch %s\n", branchName) + fmt.Printf("Creating worktree for remote branch %s\n", ui.Branch(branchName)) if err := gitClient.AddWorktreeFromRemote(worktreePath, branchName); err != nil { return fmt.Errorf("failed to create worktree: %w", err) } if !dryRun { - fmt.Printf("✓ Created worktree at %s (tracking origin/%s)\n", worktreePath, branchName) - fmt.Printf("\nTo switch to this worktree, run:\n cd %s\n", worktreePath) + fmt.Println(ui.Success(fmt.Sprintf("Created worktree at %s (tracking origin/%s)", worktreePath, branchName))) + fmt.Printf("\nTo switch to this worktree, run:\n %s\n", ui.Command(fmt.Sprintf("cd %s", worktreePath))) } return nil } @@ -177,7 +178,7 @@ func createWorktreeForExisting(gitClient git.GitClient, branchName, worktreePath return fmt.Errorf("failed to get current branch: %w", err) } - fmt.Printf("Creating new branch %s from %s\n", branchName, currentBranch) + fmt.Printf("Creating new branch %s from %s\n", ui.Branch(branchName), ui.Branch(currentBranch)) if err := gitClient.AddWorktreeNewBranch(worktreePath, branchName, currentBranch); err != nil { return fmt.Errorf("failed to create worktree: %w", err) } @@ -189,9 +190,9 @@ func createWorktreeForExisting(gitClient git.GitClient, branchName, worktreePath } if !dryRun { - fmt.Printf("✓ Created worktree at %s\n", worktreePath) - fmt.Printf("✓ Branch %s with parent %s\n", branchName, currentBranch) - fmt.Printf("\nTo switch to this worktree, run:\n cd %s\n", worktreePath) + fmt.Println(ui.Success(fmt.Sprintf("Created worktree at %s", worktreePath))) + fmt.Println(ui.Success(fmt.Sprintf("Branch %s with parent %s", ui.Branch(branchName), ui.Branch(currentBranch)))) + fmt.Printf("\nTo switch to this worktree, run:\n %s\n", ui.Command(fmt.Sprintf("cd %s", worktreePath))) } return nil } @@ -267,7 +268,7 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) fmt.Printf("Found %d worktree(s) with merged PRs:\n", len(mergedWorktrees)) for _, wt := range mergedWorktrees { pr := prCache[wt.branch] - fmt.Printf(" - %s (%s, PR #%d)\n", wt.branch, wt.path, pr.Number) + fmt.Printf(" - %s (%s, PR #%d)\n", ui.Branch(wt.branch), wt.path, pr.Number) } fmt.Println() @@ -278,17 +279,18 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) // Remove each worktree for i, wt := range mergedWorktrees { - fmt.Printf("(%d/%d) Removing worktree for %s...\n", i+1, len(mergedWorktrees), wt.branch) + fmt.Printf("%s Removing worktree for %s...\n", ui.Progress(i+1, len(mergedWorktrees)), ui.Branch(wt.branch)) if err := gitClient.RemoveWorktree(wt.path); err != nil { fmt.Fprintf(os.Stderr, " Warning: failed to remove worktree: %v\n", err) } else { - fmt.Println(" ✓ Removed") + fmt.Printf(" %s Removed\n", ui.SuccessIcon()) } } - fmt.Println("\n✓ Worktree prune complete!") - fmt.Println("Tip: Run 'stack prune' to also delete the merged branches.") + fmt.Println() + fmt.Println(ui.Success("Worktree prune complete!")) + fmt.Printf("Tip: Run '%s' to also delete the merged branches.\n", ui.Command("stack prune")) return nil } diff --git a/go.mod b/go.mod index 422d0d7..1cfc1a2 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,13 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/sys v0.25.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6f5b07d..4c5ae89 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,15 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -14,6 +21,10 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/spinner/spinner.go b/internal/spinner/spinner.go index af27e0c..a695119 100644 --- a/internal/spinner/spinner.go +++ b/internal/spinner/spinner.go @@ -6,6 +6,8 @@ import ( "os" "sync" "time" + + "github.com/fatih/color" ) // Enabled controls whether spinners are displayed (disabled in verbose mode) @@ -127,6 +129,12 @@ func Wrap(message string, fn func() error) error { return err } +// Color helpers for spinner output +var ( + green = color.New(color.FgGreen) + red = color.New(color.FgRed) +) + // WrapWithSuccess runs a function with a spinner and shows success/error message func WrapWithSuccess(message, successMessage string, fn func() error) error { if !Enabled { @@ -134,17 +142,17 @@ func WrapWithSuccess(message, successMessage string, fn func() error) error { fmt.Println(message) err := fn() if err != nil { - fmt.Printf("✗ Error: %v\n", err) + fmt.Printf("%s Error: %v\n", red.Sprint("✗"), err) } return err } sp := New(message).Start() err := fn() if err != nil { - sp.Stop(fmt.Sprintf("✗ %s: %v", message, err)) + sp.Stop(fmt.Sprintf("%s %s: %v", red.Sprint("✗"), message, err)) return err } - sp.Stop(fmt.Sprintf("✓ %s", successMessage)) + sp.Stop(fmt.Sprintf("%s %s", green.Sprint("✓"), successMessage)) return nil } diff --git a/internal/ui/color.go b/internal/ui/color.go new file mode 100644 index 0000000..696fd53 --- /dev/null +++ b/internal/ui/color.go @@ -0,0 +1,108 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/fatih/color" +) + +// Color functions - these respect NoColor setting automatically +var ( + cyan = color.New(color.FgCyan) + green = color.New(color.FgGreen) + boldGreen = color.New(color.FgGreen, color.Bold) + magenta = color.New(color.FgMagenta) + red = color.New(color.FgRed) + yellow = color.New(color.FgYellow) + dim = color.New(color.Faint) +) + +// Branch returns a branch name in cyan +func Branch(name string) string { + return cyan.Sprint(name) +} + +// CurrentBranchMarker returns the bold green asterisk for current branch +func CurrentBranchMarker() string { + return boldGreen.Sprint(" *") +} + +// PRState returns the PR state with appropriate coloring +func PRState(state string) string { + switch strings.ToUpper(state) { + case "OPEN": + return green.Sprint(strings.ToLower(state)) + case "MERGED": + return magenta.Sprint(strings.ToLower(state)) + case "CLOSED": + return red.Sprint(strings.ToLower(state)) + default: + return strings.ToLower(state) + } +} + +// Success returns a green success message with checkmark +func Success(msg string) string { + return green.Sprintf("✓ %s", msg) +} + +// Warning returns a yellow warning message with warning sign +func Warning(msg string) string { + return yellow.Sprintf("⚠ %s", msg) +} + +// Error returns a red error message with X +func Error(msg string) string { + return red.Sprintf("✗ %s", msg) +} + +// ErrorText returns red text without the X prefix +func ErrorText(msg string) string { + return red.Sprint(msg) +} + +// Command returns a command in cyan (for help text) +func Command(cmd string) string { + return cyan.Sprint(cmd) +} + +// Dim returns dimmed/gray text +func Dim(s string) string { + return dim.Sprint(s) +} + +// Progress returns progress indicators like (1/5) in dim +func Progress(current, total int) string { + return dim.Sprintf("(%d/%d)", current, total) +} + +// Pipe returns the tree pipe character in dim +func Pipe() string { + return dim.Sprint("|") +} + +// SuccessIcon returns just the green checkmark +func SuccessIcon() string { + return green.Sprint("✓") +} + +// WarningIcon returns just the yellow warning sign +func WarningIcon() string { + return yellow.Sprint("⚠") +} + +// ErrorIcon returns just the red X +func ErrorIcon() string { + return red.Sprint("✗") +} + +// PRInfo formats PR information with URL and colored state +func PRInfo(url, state string) string { + return fmt.Sprintf("[%s :%s]", url, PRState(state)) +} + +// SetNoColor sets whether color output is disabled +func SetNoColor(disabled bool) { + color.NoColor = disabled +}