From 895d6c499dfc7c9db8842b7e42a3521d9c61873d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 6 Feb 2026 12:59:56 -0800 Subject: [PATCH 1/2] feat: add ANSI color output following GitHub CLI conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add colored terminal output to improve readability and user experience, following the GitHub CLI Primer color guidelines. - Add `internal/style` package using `mgutz/ansi` for ANSI colors - Respect `NO_COLOR`, `CLICOLOR`, and TTY detection - Apply semantic colors: green (success), red (errors), yellow (warnings), cyan (branches), magenta (merged), gray (muted), bold (headers) - Add icons: ✓ (success), ✗ (failure), ! (warning) - Update all cmd files and tree formatting with color support - Add `.claude/rules/colors.md` documenting the color scheme Co-authored-by: Cursor --- .claude/rules/colors.md | 77 ++++++++++++++++ cmd/abort.go | 7 +- cmd/adopt.go | 4 +- cmd/cascade.go | 42 +++++---- cmd/continue.go | 17 ++-- cmd/create.go | 7 +- cmd/init.go | 7 +- cmd/link.go | 4 +- cmd/log.go | 20 +++-- cmd/orphan.go | 7 +- cmd/submit.go | 111 ++++++++++++----------- cmd/sync.go | 93 +++++++++---------- cmd/undo.go | 43 ++++----- cmd/unlink.go | 4 +- internal/style/style.go | 193 ++++++++++++++++++++++++++++++++++++++++ internal/tree/tree.go | 16 +++- 16 files changed, 490 insertions(+), 162 deletions(-) create mode 100644 .claude/rules/colors.md create mode 100644 internal/style/style.go diff --git a/.claude/rules/colors.md b/.claude/rules/colors.md new file mode 100644 index 0000000..0045882 --- /dev/null +++ b/.claude/rules/colors.md @@ -0,0 +1,77 @@ +# Terminal Output Colors + +This project follows the [GitHub CLI Primer color conventions](https://github.com/cli/cli/tree/trunk/docs/primer/foundations) for consistent, accessible terminal output. + +## Color Scheme + +Use only the 8 basic ANSI colors for maximum terminal compatibility: + +| Color | Usage | Style Method | +| ------- | ---------------------------------- | -------------- | +| Green | Success messages, open states | `Success()` | +| Red | Errors, failures, conflicts | `Error()` | +| Yellow | Warnings, pending states | `Warning()` | +| Cyan | Branch names | `Branch()` | +| Magenta | Merged PRs | `Merged()` | +| Gray | Secondary text, hints, muted info | `Muted()` | +| Bold | Phase headers, emphasis | `Bold()` | + +## Icons + +Use these Unicode symbols to enhance (not replace) meaning: + +| Icon | Meaning | Style Method | +| ---- | -------- | ----------------- | +| `✓` | Success | `SuccessIcon()` | +| `✗` | Failure | `FailureIcon()` | +| `!` | Warning | `WarningIcon()` | + +## Usage + +Import the style package and create an instance: + +```go +import "github.com/boneskull/gh-stack/internal/style" + +s := style.New() + +// Success messages +fmt.Printf("%s Sync complete!\n", s.SuccessIcon()) +fmt.Println(s.SuccessMessage("Operation complete")) + +// Warnings +fmt.Printf("%s could not fetch PR: %v\n", s.WarningIcon(), err) + +// Branch names +fmt.Printf("Rebasing %s onto %s\n", s.Branch(current), s.Branch(parent)) + +// Phase headers +fmt.Println(s.Bold("=== Phase 1: Cascade ===")) + +// Secondary/muted text +fmt.Println(s.Muted("Run 'git config ...' to fix.")) +``` + +## Environment Variables + +The style package respects standard terminal conventions: + +- `NO_COLOR` - Disables all colors when set (any value) +- `CLICOLOR=0` - Disables colors +- `CLICOLOR_FORCE=1` - Forces colors even in non-TTY +- `GH_FORCE_TTY` - Forces TTY behavior (from go-gh) + +## Scriptability + +When output is piped (non-TTY), colors are automatically disabled. This ensures: + +- Machine-readable output when piped to other commands +- State is communicated via text, not just color +- Compatibility with tools like `grep`, `awk`, `cut` + +## Guidelines + +1. **Color enhances, never communicates alone** - Always include text that conveys the meaning +2. **Be consistent** - Use the same color for the same semantic meaning +3. **Test without colors** - Run with `NO_COLOR=1` to verify output is still clear +4. **Prefer semantic methods** - Use `Success()` not raw green for success messages diff --git a/cmd/abort.go b/cmd/abort.go index e39b5a2..8d130c3 100644 --- a/cmd/abort.go +++ b/cmd/abort.go @@ -7,6 +7,7 @@ import ( "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/style" "github.com/spf13/cobra" ) @@ -22,6 +23,8 @@ func init() { } func runAbort(cmd *cobra.Command, args []string) error { + s := style.New() + cwd, err := os.Getwd() if err != nil { return err @@ -50,10 +53,10 @@ func runAbort(cmd *cobra.Command, args []string) error { if st.StashRef != "" { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(st.StashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr) } } - fmt.Printf("Cascade aborted. Original HEAD was %s\n", st.OriginalHead) + fmt.Printf("%s Cascade aborted. Original HEAD was %s\n", s.WarningIcon(), st.OriginalHead) return nil } diff --git a/cmd/adopt.go b/cmd/adopt.go index 2bdf4cd..a2b9bb3 100644 --- a/cmd/adopt.go +++ b/cmd/adopt.go @@ -7,6 +7,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) @@ -103,6 +104,7 @@ func runAdopt(cmd *cobra.Command, args []string) error { _ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort } - fmt.Printf("Adopted branch %q with parent %q\n", branchName, parent) + s := style.New() + fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent)) return nil } diff --git a/cmd/cascade.go b/cmd/cascade.go index 5e3bebc..e1efa4c 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -9,6 +9,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/boneskull/gh-stack/internal/undo" "github.com/spf13/cobra" @@ -36,6 +37,8 @@ func init() { } func runCascade(cmd *cobra.Command, args []string) error { + s := style.New() + cwd, err := os.Getwd() if err != nil { return err @@ -81,19 +84,19 @@ func runCascade(cmd *cobra.Command, args []string) error { var stashRef string if !cascadeDryRunFlag { var saveErr error - stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade") + stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade", s) if saveErr != nil { - fmt.Printf("Warning: could not save undo state: %v\n", saveErr) + fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr) } } - err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef) + err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s) // Restore auto-stashed changes after operation (unless conflict, which saves stash in state) if stashRef != "" && err != ErrConflict { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(stashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(stashRef), popErr) } } @@ -103,7 +106,7 @@ func runCascade(cmd *cobra.Command, args []string) error { // doCascadeWithState performs cascade and saves state with the given operation type. // allBranches is the complete list of branches for submit operations (used for push/PR after continue). // stashRef is the commit hash of auto-stashed changes (if any), persisted to state on conflict. -func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string) error { +func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string, s *style.Style) error { originalBranch, err := g.CurrentBranch() if err != nil { return err @@ -126,12 +129,12 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d } if !needsRebase { - fmt.Printf("Cascading %s... already up to date\n", b.Name) + fmt.Printf("Cascading %s... %s\n", s.Branch(b.Name), s.Muted("already up to date")) continue } if dryRun { - fmt.Printf("Would rebase %s onto %s\n", b.Name, parent) + fmt.Printf("%s Would rebase %s onto %s\n", s.Muted("dry-run:"), s.Branch(b.Name), s.Branch(parent)) continue } @@ -150,9 +153,9 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d } if useOnto { - fmt.Printf("Cascading %s onto %s (using fork point)...\n", b.Name, parent) + fmt.Printf("Cascading %s onto %s %s...\n", s.Branch(b.Name), s.Branch(parent), s.Muted("(using fork point)")) } else { - fmt.Printf("Cascading %s onto %s...\n", b.Name, parent) + fmt.Printf("Cascading %s onto %s...\n", s.Branch(b.Name), s.Branch(parent)) } // Checkout and rebase @@ -187,15 +190,15 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d } _ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually - fmt.Printf("\nCONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel.\n") + fmt.Printf("\n%s %s\n", s.FailureIcon(), s.Error("CONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel.")) fmt.Printf("Remaining branches: %v\n", remaining) if stashRef != "" { - fmt.Printf("Note: Your uncommitted changes are stashed and will be restored when you continue or abort.\n") + fmt.Println(s.Muted("Note: Your uncommitted changes are stashed and will be restored when you continue or abort.")) } return ErrConflict } - fmt.Printf("Cascading %s... ok\n", b.Name) + fmt.Printf("Cascading %s... %s\n", s.Branch(b.Name), s.Success("ok")) // Update fork point to current parent tip parentTip, tipErr := g.GetTip(parent) @@ -217,7 +220,7 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d // branches: branches that will be modified (rebased) // deletedBranches: branches that will be deleted (for sync) // Returns the stash ref (commit hash) if changes were stashed, empty string otherwise. -func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string) (string, error) { +func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string, s *style.Style) (string, error) { gitDir := g.GetGitDir() // Get current branch for original head @@ -243,7 +246,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del } if stashRef != "" { snapshot.StashRef = stashRef - fmt.Println("Auto-stashed uncommitted changes") + fmt.Println(s.Muted("Auto-stashed uncommitted changes")) } } @@ -252,7 +255,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del bs, captureErr := captureBranchState(g, cfg, node.Name) if captureErr != nil { // Non-fatal: log warning and continue - fmt.Printf("Warning: could not capture state for %s: %v\n", node.Name, captureErr) + fmt.Printf("%s could not capture state for %s: %v\n", s.WarningIcon(), s.Branch(node.Name), captureErr) continue } snapshot.Branches[node.Name] = bs @@ -262,7 +265,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del for _, node := range deletedBranches { bs, captureErr := captureBranchState(g, cfg, node.Name) if captureErr != nil { - fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", node.Name, captureErr) + fmt.Printf("%s could not capture state for deleted branch %s: %v\n", s.WarningIcon(), s.Branch(node.Name), captureErr) continue } snapshot.DeletedBranches[node.Name] = bs @@ -279,6 +282,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del // Useful for sync where we don't always have tree nodes. // Returns the stash ref (commit hash) if changes were stashed, empty string otherwise. func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string) (string, error) { + s := style.New() gitDir := g.GetGitDir() // Get current branch for original head @@ -304,7 +308,7 @@ func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string } if stashRef != "" { snapshot.StashRef = stashRef - fmt.Println("Auto-stashed uncommitted changes") + fmt.Println(s.Muted("Auto-stashed uncommitted changes")) } } @@ -312,7 +316,7 @@ func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string for _, name := range branchNames { bs, captureErr := captureBranchState(g, cfg, name) if captureErr != nil { - fmt.Printf("Warning: could not capture state for %s: %v\n", name, captureErr) + fmt.Printf("%s could not capture state for %s: %v\n", s.WarningIcon(), s.Branch(name), captureErr) continue } snapshot.Branches[name] = bs @@ -322,7 +326,7 @@ func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string for _, name := range deletedBranchNames { bs, captureErr := captureBranchState(g, cfg, name) if captureErr != nil { - fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", name, captureErr) + fmt.Printf("%s could not capture state for deleted branch %s: %v\n", s.WarningIcon(), s.Branch(name), captureErr) continue } snapshot.DeletedBranches[name] = bs diff --git a/cmd/continue.go b/cmd/continue.go index b5060a3..d853e16 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -8,6 +8,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) @@ -24,6 +25,8 @@ func init() { } func runContinue(cmd *cobra.Command, args []string) error { + s := style.New() + cwd, err := os.Getwd() if err != nil { return err @@ -45,7 +48,7 @@ func runContinue(cmd *cobra.Command, args []string) error { } } - fmt.Printf("Completed %s\n", st.Current) + fmt.Printf("%s Completed %s\n", s.SuccessIcon(), s.Branch(st.Current)) cfg, err := config.Load(cwd) if err != nil { @@ -70,12 +73,12 @@ func runContinue(cmd *cobra.Command, args []string) error { // Remove state file before continuing (will be recreated if conflict) _ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup - if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef); cascadeErr != nil { + if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef, s); cascadeErr != nil { // Stash handling is done by doCascadeWithState (conflict saves in state, errors restore) if cascadeErr != ErrConflict && st.StashRef != "" { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(st.StashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr) } } return cascadeErr // Another conflict - state saved @@ -111,12 +114,12 @@ func runContinue(cmd *cobra.Command, args []string) error { allBranches = append(allBranches, node) } - err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web, st.PushOnly) + err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web, st.PushOnly, s) // Restore stash after submit completes if st.StashRef != "" { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(st.StashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr) } } return err @@ -126,10 +129,10 @@ func runContinue(cmd *cobra.Command, args []string) error { if st.StashRef != "" { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(st.StashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr) } } - fmt.Println("Cascade complete!") + fmt.Println(s.SuccessMessage("Cascade complete!")) return nil } diff --git a/cmd/create.go b/cmd/create.go index d4d6381..b1f7862 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -7,6 +7,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/spf13/cobra" ) @@ -84,12 +85,14 @@ func runCreate(cmd *cobra.Command, args []string) error { return err } + s := style.New() + // Commit staged changes if any if hasStaged && !createEmptyFlag && createMessageFlag != "" { if err := g.Commit(createMessageFlag); err != nil { return err } - fmt.Printf("Committed staged changes: %s\n", createMessageFlag) + fmt.Printf("%s Committed staged changes: %s\n", s.SuccessIcon(), createMessageFlag) } // Set parent @@ -103,6 +106,6 @@ func runCreate(cmd *cobra.Command, args []string) error { _ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort } - fmt.Printf("Created branch %q stacked on %q\n", branchName, currentBranch) + fmt.Printf("%s Created branch %s stacked on %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(currentBranch)) return nil } diff --git a/cmd/init.go b/cmd/init.go index 0250289..f0b1c55 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -7,6 +7,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/spf13/cobra" ) @@ -55,9 +56,11 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("branch %q does not exist", trunk) } + s := style.New() + // Check if already initialized if existing, err := cfg.GetTrunk(); err == nil { - fmt.Printf("Already initialized with trunk %q\n", existing) + fmt.Printf("%s Already initialized with trunk %s\n", s.Muted("ℹ"), s.Branch(existing)) return nil } @@ -65,6 +68,6 @@ func runInit(cmd *cobra.Command, args []string) error { return err } - fmt.Printf("Initialized stack tracking with trunk %q\n", trunk) + fmt.Printf("%s Initialized stack tracking with trunk %s\n", s.SuccessIcon(), s.Branch(trunk)) return nil } diff --git a/cmd/link.go b/cmd/link.go index d9b48b2..2d1228e 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -8,6 +8,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/spf13/cobra" ) @@ -54,6 +55,7 @@ func runLink(cmd *cobra.Command, args []string) error { return err } - fmt.Printf("Linked PR #%d to branch %q\n", prNumber, branch) + s := style.New() + fmt.Printf("%s Linked PR #%d to branch %s\n", s.SuccessIcon(), prNumber, s.Branch(branch)) return nil } diff --git a/cmd/log.go b/cmd/log.go index 77cdc97..d53c163 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -9,6 +9,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/github" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/cli/go-gh/v2/pkg/tableprinter" "github.com/cli/go-gh/v2/pkg/term" @@ -55,11 +56,14 @@ func runLog(cmd *cobra.Command, args []string) error { // Try to get GitHub client for PR URLs (optional - may fail if not in a GitHub repo) gh, _ := github.NewClient() //nolint:errcheck // nil is fine, URLs won't be shown + s := style.New() + if logPorcelainFlag { - printPorcelain(root, currentBranch, gh) + printPorcelain(root, currentBranch, gh, s) } else { opts := tree.FormatOptions{ CurrentBranch: currentBranch, + Style: s, } if gh != nil { opts.PRURLFunc = gh.PRURL @@ -79,7 +83,7 @@ func runLog(cmd *cobra.Command, args []string) error { // - PARENT: parent branch name (empty for trunk) // - PR: associated PR number (empty if none) // - URL: full PR URL (empty if no PR or GitHub client unavailable) -func printPorcelain(node *tree.Node, current string, gh *github.Client) { +func printPorcelain(node *tree.Node, current string, gh *github.Client, s *style.Style) { t := term.FromEnv() isTTY := t.IsTerminalOutput() @@ -108,12 +112,18 @@ func printPorcelain(node *tree.Node, current string, gh *github.Client) { addNode = func(n *tree.Node) { branchName := n.Name if isTTY && n.Name == current { - branchName = "* " + n.Name + branchName = s.Bold("* " + n.Name) + } else if isTTY { + branchName = s.Branch(n.Name) } parent := "" if n.Parent != nil { - parent = n.Parent.Name + if isTTY { + parent = s.Branch(n.Parent.Name) + } else { + parent = n.Parent.Name + } } prNum := "" @@ -139,6 +149,6 @@ func printPorcelain(node *tree.Node, current string, gh *github.Client) { addNode(node) if err := tp.Render(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to render table: %v\n", err) + fmt.Fprintf(os.Stderr, "%s failed to render table: %v\n", s.WarningIcon(), err) } } diff --git a/cmd/orphan.go b/cmd/orphan.go index 1e50e42..66d5aa9 100644 --- a/cmd/orphan.go +++ b/cmd/orphan.go @@ -7,6 +7,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) @@ -66,6 +67,8 @@ func runOrphan(cmd *cobra.Command, args []string) error { return fmt.Errorf("branch %q has children; use --force to orphan descendants too", branchName) } + s := style.New() + // Orphan descendants if --force if orphanForceFlag { descendants := tree.GetDescendants(node) @@ -73,7 +76,7 @@ func runOrphan(cmd *cobra.Command, args []string) error { _ = cfg.RemoveParent(desc.Name) //nolint:errcheck // best effort cleanup _ = cfg.RemovePR(desc.Name) //nolint:errcheck // best effort cleanup _ = cfg.RemoveForkPoint(desc.Name) //nolint:errcheck // best effort cleanup - fmt.Printf("Orphaned %q\n", desc.Name) + fmt.Printf("%s Orphaned %s\n", s.SuccessIcon(), s.Branch(desc.Name)) } } @@ -81,7 +84,7 @@ func runOrphan(cmd *cobra.Command, args []string) error { _ = cfg.RemoveParent(branchName) //nolint:errcheck // best effort cleanup _ = cfg.RemovePR(branchName) //nolint:errcheck // best effort cleanup _ = cfg.RemoveForkPoint(branchName) //nolint:errcheck // best effort cleanup - fmt.Printf("Orphaned %q\n", branchName) + fmt.Printf("%s Orphaned %s\n", s.SuccessIcon(), s.Branch(branchName)) return nil } diff --git a/cmd/submit.go b/cmd/submit.go index 7c25cc2..e2a2671 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -11,6 +11,7 @@ import ( "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/cli/go-gh/v2/pkg/browser" "github.com/spf13/cobra" @@ -51,6 +52,8 @@ func init() { } func runSubmit(cmd *cobra.Command, args []string) error { + s := style.New() + // Validate flag combinations if submitPushOnlyFlag && submitUpdateOnlyFlag { return fmt.Errorf("--push-only and --update-only cannot be used together: --push-only skips all PR operations") @@ -126,33 +129,33 @@ func runSubmit(cmd *cobra.Command, args []string) error { var stashRef string if !submitDryRunFlag { var saveErr error - stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "submit", "gh stack submit") + stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "submit", "gh stack submit", s) if saveErr != nil { - fmt.Printf("Warning: could not save undo state: %v\n", saveErr) + fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr) } } // Phase 1: Cascade - fmt.Println("=== Phase 1: Cascade ===") - if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef); cascadeErr != nil { + fmt.Println(s.Bold("=== Phase 1: Cascade ===")) + if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, s); cascadeErr != nil { // Stash is saved in state for conflicts; restore on other errors if cascadeErr != ErrConflict && stashRef != "" { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(stashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(stashRef), popErr) } } return cascadeErr } // Phases 2 & 3 - err = doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag) + err = doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, s) // Restore auto-stashed changes after operation completes if stashRef != "" { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(stashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(stashRef), popErr) } } @@ -161,34 +164,34 @@ func runSubmit(cmd *cobra.Command, args []string) error { // doSubmitPushAndPR handles push and PR creation/update phases. // This is called after cascade succeeds (or from continue after conflict resolution). -func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb, pushOnly bool) error { +func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb, pushOnly bool, s *style.Style) error { // Phase 2: Push all branches - fmt.Println("\n=== Phase 2: Push ===") + fmt.Println(s.Bold("\n=== Phase 2: Push ===")) for _, b := range branches { if dryRun { - fmt.Printf("Would push %s -> origin/%s (forced)\n", b.Name, b.Name) + fmt.Printf("%s Would push %s -> origin/%s (forced)\n", s.Muted("dry-run:"), s.Branch(b.Name), s.Branch(b.Name)) } else { - fmt.Printf("Pushing %s -> origin/%s (forced)... ", b.Name, b.Name) + fmt.Printf("Pushing %s -> origin/%s (forced)... ", s.Branch(b.Name), s.Branch(b.Name)) if err := g.Push(b.Name, true); err != nil { - fmt.Println("failed") + fmt.Println(s.Error("failed")) return fmt.Errorf("failed to push %s: %w", b.Name, err) } - fmt.Println("ok") + fmt.Println(s.Success("ok")) } } // Phase 3: Create/update PRs if pushOnly { - fmt.Println("\n=== Phase 3: PRs ===") - fmt.Println("Skipped (--push-only)") + fmt.Println(s.Bold("\n=== Phase 3: PRs ===")) + fmt.Println(s.Muted("Skipped (--push-only)")) return nil } - return doSubmitPRs(g, cfg, root, branches, dryRun, updateOnly, openWeb) + return doSubmitPRs(g, cfg, root, branches, dryRun, updateOnly, openWeb, s) } // doSubmitPRs handles PR creation/update for all branches. -func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb bool) error { - fmt.Println("\n=== Phase 3: PRs ===") +func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb bool, s *style.Style) error { + fmt.Println(s.Bold("\n=== Phase 3: PRs ===")) trunk, err := cfg.GetTrunk() if err != nil { @@ -219,48 +222,48 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr if existingPR > 0 { // Update existing PR if dryRun { - fmt.Printf("Would update PR #%d base to %q\n", existingPR, parent) + fmt.Printf("%s Would update PR #%d base to %s\n", s.Muted("dry-run:"), existingPR, s.Branch(parent)) } else { - fmt.Printf("Updating PR #%d for %s (base: %s)... ", existingPR, b.Name, parent) + fmt.Printf("Updating PR #%d for %s (base: %s)... ", existingPR, s.Branch(b.Name), s.Branch(parent)) if err := ghClient.UpdatePRBase(existingPR, parent); err != nil { - fmt.Println("failed") - fmt.Printf("Warning: failed to update PR #%d base: %v\n", existingPR, err) + fmt.Println(s.Error("failed")) + fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), existingPR, err) } else { - fmt.Println("ok") + fmt.Println(s.Success("ok")) if openWeb { prURLs = append(prURLs, ghClient.PRURL(existingPR)) } } // Update stack comment if err := ghClient.GenerateAndPostStackComment(root, b.Name, trunk, existingPR); err != nil { - fmt.Printf("Warning: failed to update stack comment for PR #%d: %v\n", existingPR, err) + fmt.Printf("%s failed to update stack comment for PR #%d: %v\n", s.WarningIcon(), existingPR, err) } // If PR is a draft and now targets trunk, offer to publish - maybeMarkPRReady(ghClient, existingPR, b.Name, parent, trunk) + maybeMarkPRReady(ghClient, existingPR, b.Name, parent, trunk, s) } } else if !updateOnly { // Create new PR if dryRun { - fmt.Printf("Would create PR for %s (base: %s)\n", b.Name, parent) + fmt.Printf("%s Would create PR for %s (base: %s)\n", s.Muted("dry-run:"), s.Branch(b.Name), s.Branch(parent)) } else { - prNum, adopted, err := createPRForBranch(g, ghClient, cfg, root, b.Name, parent, trunk) + prNum, adopted, err := createPRForBranch(g, ghClient, cfg, root, b.Name, parent, trunk, s) if err != nil { - fmt.Printf("Warning: failed to create PR for %s: %v\n", b.Name, err) + fmt.Printf("%s failed to create PR for %s: %v\n", s.WarningIcon(), s.Branch(b.Name), err) } else if adopted { - fmt.Printf("Adopted PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum)) + fmt.Printf("%s Adopted PR #%d for %s (%s)\n", s.SuccessIcon(), prNum, s.Branch(b.Name), ghClient.PRURL(prNum)) if openWeb { prURLs = append(prURLs, ghClient.PRURL(prNum)) } } else { - fmt.Printf("Created PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum)) + fmt.Printf("%s Created PR #%d for %s (%s)\n", s.SuccessIcon(), prNum, s.Branch(b.Name), ghClient.PRURL(prNum)) if openWeb { prURLs = append(prURLs, ghClient.PRURL(prNum)) } } } } else { - fmt.Printf("Skipping %s (no existing PR, --update-only)\n", b.Name) + fmt.Printf("Skipping %s %s\n", s.Branch(b.Name), s.Muted("(no existing PR, --update-only)")) } } @@ -269,7 +272,7 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr b := browser.New("", os.Stdout, os.Stderr) for _, url := range prURLs { if err := b.Browse(url); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not open browser for %s: %v\n", url, err) + fmt.Fprintf(os.Stderr, "%s could not open browser for %s: %v\n", s.WarningIcon(), url, err) } } } @@ -280,7 +283,7 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr // createPRForBranch creates a PR for the given branch and stores the PR number. // If a PR already exists for the branch, it adopts the existing PR instead. // Returns (prNumber, adopted, error) where adopted is true if we adopted an existing PR. -func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, bool, error) { +func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string, s *style.Style) (int, bool, error) { // Determine if draft (not targeting trunk = middle of stack) draft := base != trunk @@ -291,12 +294,12 @@ func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, defaultBody, bodyErr := generatePRBody(g, base, branch) if bodyErr != nil { // Non-fatal: just skip auto-body - fmt.Printf("Warning: could not generate PR body: %v\n", bodyErr) + fmt.Printf("%s could not generate PR body: %v\n", s.WarningIcon(), bodyErr) defaultBody = "" } // Get title and body (prompt if interactive and --yes not set) - title, body, err := promptForPRDetails(branch, defaultTitle, defaultBody) + title, body, err := promptForPRDetails(branch, defaultTitle, defaultBody, s) if err != nil { return 0, false, fmt.Errorf("failed to get PR details: %w", err) } @@ -305,7 +308,7 @@ func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, if err != nil { // Check if PR already exists - if so, adopt it if strings.Contains(err.Error(), "pull request already exists") { - prNum, adoptErr := adoptExistingPR(ghClient, cfg, root, branch, base, trunk) + prNum, adoptErr := adoptExistingPR(ghClient, cfg, root, branch, base, trunk, s) return prNum, true, adoptErr } return 0, false, err @@ -323,7 +326,7 @@ func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, // Add stack navigation comment if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, pr.Number); err != nil { - fmt.Printf("Warning: failed to add stack comment to PR #%d: %v\n", pr.Number, err) + fmt.Printf("%s failed to add stack comment to PR #%d: %v\n", s.WarningIcon(), pr.Number, err) } return pr.Number, false, nil @@ -371,7 +374,7 @@ func toTitleCase(s string) string { // promptForPRDetails prompts the user for PR title and body. // If --yes flag is set or stdin is not a TTY, returns the defaults without prompting. -func promptForPRDetails(branch, defaultTitle, defaultBody string) (title, body string, err error) { +func promptForPRDetails(branch, defaultTitle, defaultBody string, s *style.Style) (title, body string, err error) { // Skip prompts if --yes flag is set if submitYesFlag { return defaultTitle, defaultBody, nil @@ -382,7 +385,7 @@ func promptForPRDetails(branch, defaultTitle, defaultBody string) (title, body s return defaultTitle, defaultBody, nil } - fmt.Printf("\n--- Creating PR for %s (use --yes to skip prompts) ---\n", branch) + fmt.Printf("\n--- Creating PR for %s %s ---\n", s.Branch(branch), s.Muted("(use --yes to skip prompts)")) // Prompt for title title, err = prompt.Input("PR title", defaultTitle) @@ -396,19 +399,19 @@ func promptForPRDetails(branch, defaultTitle, defaultBody string) (title, body s // Show the generated body and ask if user wants to edit if defaultBody != "" { - fmt.Println("\nGenerated PR description:") - fmt.Println("---") + fmt.Println(s.Muted("\nGenerated PR description:")) + fmt.Println(s.Muted("---")) // Show first few lines or truncate if too long lines := strings.Split(defaultBody, "\n") if len(lines) > 10 { for _, line := range lines[:10] { fmt.Println(line) } - fmt.Printf("... (%d more lines)\n", len(lines)-10) + fmt.Printf(s.Muted("... (%d more lines)\n"), len(lines)-10) } else { fmt.Println(defaultBody) } - fmt.Println("---") + fmt.Println(s.Muted("---")) } editBody, err := prompt.Confirm("Edit description in editor?", false) @@ -419,7 +422,7 @@ func promptForPRDetails(branch, defaultTitle, defaultBody string) (title, body s if editBody { body, err = prompt.EditInEditor(defaultBody) if err != nil { - fmt.Printf("Warning: editor failed, using generated description: %v\n", err) + fmt.Printf("%s editor failed, using generated description: %v\n", s.WarningIcon(), err) body = defaultBody } } else { @@ -431,7 +434,7 @@ func promptForPRDetails(branch, defaultTitle, defaultBody string) (title, body s } // adoptExistingPR finds an existing PR for the branch and adopts it into the stack. -func adoptExistingPR(ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, error) { +func adoptExistingPR(ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string, s *style.Style) (int, error) { existingPR, err := ghClient.FindPRByHead(branch) if err != nil { return 0, fmt.Errorf("failed to find existing PR: %w", err) @@ -453,18 +456,18 @@ func adoptExistingPR(ghClient *github.Client, cfg *config.Config, root *tree.Nod // Update PR base to match stack parent if existingPR.Base.Ref != base { if err := ghClient.UpdatePRBase(existingPR.Number, base); err != nil { - fmt.Printf("Warning: failed to update base: %v\n", err) + fmt.Printf("%s failed to update base: %v\n", s.WarningIcon(), err) } } // Add/update stack navigation comment if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, existingPR.Number); err != nil { - fmt.Printf("Warning: failed to update stack comment: %v\n", err) + fmt.Printf("%s failed to update stack comment: %v\n", s.WarningIcon(), err) } // If adopted PR is a draft and targets trunk, offer to publish if existingPR.Draft && base == trunk { - promptMarkPRReady(ghClient, existingPR.Number, branch, trunk) + promptMarkPRReady(ghClient, existingPR.Number, branch, trunk, s) } return existingPR.Number, nil @@ -473,7 +476,7 @@ func adoptExistingPR(ghClient *github.Client, cfg *config.Config, root *tree.Nod // maybeMarkPRReady checks if a PR is a draft targeting trunk and offers to publish it. // This handles the case where a PR was created as a draft (middle of stack) but now // targets trunk because its parent was merged. -func maybeMarkPRReady(ghClient *github.Client, prNumber int, branch, base, trunk string) { +func maybeMarkPRReady(ghClient *github.Client, prNumber int, branch, base, trunk string, s *style.Style) { // Only relevant if PR now targets trunk if base != trunk { return @@ -485,13 +488,13 @@ func maybeMarkPRReady(ghClient *github.Client, prNumber int, branch, base, trunk return } - promptMarkPRReady(ghClient, prNumber, branch, trunk) + promptMarkPRReady(ghClient, prNumber, branch, trunk, s) } // promptMarkPRReady prompts to publish a draft PR and marks it ready if confirmed. // Called when we already know the PR is a draft targeting trunk. -func promptMarkPRReady(ghClient *github.Client, prNumber int, branch, trunk string) { - fmt.Printf("PR #%d (%s) is a draft and now targets %s.\n", prNumber, branch, trunk) +func promptMarkPRReady(ghClient *github.Client, prNumber int, branch, trunk string, s *style.Style) { + fmt.Printf("PR #%d (%s) is a draft and now targets %s.\n", prNumber, s.Branch(branch), s.Branch(trunk)) // Skip prompt if --yes flag is set or non-interactive shouldMarkReady := true @@ -501,9 +504,9 @@ func promptMarkPRReady(ghClient *github.Client, prNumber int, branch, trunk stri if shouldMarkReady { if readyErr := ghClient.MarkPRReady(prNumber); readyErr != nil { - fmt.Printf("Warning: failed to mark PR ready: %v\n", readyErr) + fmt.Printf("%s failed to mark PR ready: %v\n", s.WarningIcon(), readyErr) } else { - fmt.Printf("PR #%d marked as ready for review.\n", prNumber) + fmt.Printf("%s PR #%d marked as ready for review.\n", s.SuccessIcon(), prNumber) } } } diff --git a/cmd/sync.go b/cmd/sync.go index e69bc28..23cb12b 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -10,6 +10,7 @@ import ( "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) @@ -33,7 +34,7 @@ func init() { } // updateStackComments updates the navigation comment on all PRs in the stack. -func updateStackComments(cfg *config.Config, gh *github.Client) error { +func updateStackComments(cfg *config.Config, gh *github.Client, s *style.Style) error { trunk, err := cfg.GetTrunk() if err != nil { return err @@ -53,22 +54,22 @@ func updateStackComments(cfg *config.Config, gh *github.Client) error { } // Walk tree and update each PR's comment - return walkTreeAndUpdateComments(root, root, trunk, gh, prInfo) + return walkTreeAndUpdateComments(root, root, trunk, gh, prInfo, s) } -func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.Client, prInfo map[int]github.PRInfo) error { +func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.Client, prInfo map[int]github.PRInfo, s *style.Style) error { if node.PR > 0 { comment := github.GenerateStackComment(root, node.Name, trunk, gh.RepoURL(), prInfo) if comment != "" { if err := gh.CreateOrUpdateStackComment(node.PR, comment); err != nil { - fmt.Printf("Warning: failed to update comment on PR #%d: %v\n", node.PR, err) + fmt.Printf("%s failed to update comment on PR #%d: %v\n", s.WarningIcon(), node.PR, err) // Continue with other PRs } } } for _, child := range node.Children { - if err := walkTreeAndUpdateComments(child, root, trunk, gh, prInfo); err != nil { + if err := walkTreeAndUpdateComments(child, root, trunk, gh, prInfo, s); err != nil { return err } } @@ -77,6 +78,8 @@ func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.C } func runSync(cmd *cobra.Command, args []string) error { + s := style.New() + cwd, err := os.Getwd() if err != nil { return err @@ -108,7 +111,7 @@ func runSync(cmd *cobra.Command, args []string) error { var saveErr error stashRef, saveErr = saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync") if saveErr != nil { - fmt.Printf("Warning: could not save undo state: %v\n", saveErr) + fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr) } } } @@ -122,7 +125,7 @@ func runSync(cmd *cobra.Command, args []string) error { if stashRef != "" && !hitConflict { fmt.Println("Restoring auto-stashed changes...") if popErr := g.StashPop(stashRef); popErr != nil { - fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr) + fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(stashRef), popErr) } } }() @@ -137,10 +140,10 @@ func runSync(cmd *cobra.Command, args []string) error { // Fast-forward trunk currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine - fmt.Printf("Fast-forwarding %s...\n", trunk) + fmt.Printf("Fast-forwarding %s...\n", s.Branch(trunk)) if !syncDryRunFlag { if ffErr := g.FastForward(trunk); ffErr != nil { - fmt.Printf("Warning: could not fast-forward %s: %v\n", trunk, ffErr) + fmt.Printf("%s could not fast-forward %s: %v\n", s.WarningIcon(), s.Branch(trunk), ffErr) } // Return to original branch _ = g.Checkout(currentBranch) //nolint:errcheck // best effort @@ -161,7 +164,7 @@ func runSync(cmd *cobra.Command, args []string) error { pr, getPRErr := gh.GetPR(prNum) if getPRErr != nil { - fmt.Printf("Warning: could not fetch PR #%d: %v\n", prNum, getPRErr) + fmt.Printf("%s could not fetch PR #%d: %v\n", s.WarningIcon(), prNum, getPRErr) continue } @@ -211,25 +214,25 @@ func runSync(cmd *cobra.Command, args []string) error { // Check if parent exists on remote if !g.RemoteBranchExists(parent) { - fmt.Printf("\nWarning: parent branch %q of %q does not exist on remote.\n", parent, branch) + fmt.Printf("\n%s parent branch %s of %s does not exist on remote.\n", s.WarningIcon(), s.Branch(parent), s.Branch(branch)) if prompt.IsInteractive() { retarget, _ := prompt.Confirm(fmt.Sprintf("Retarget %s to %s?", branch, trunk), true) //nolint:errcheck // default is fine if retarget { _ = cfg.SetParent(branch, trunk) //nolint:errcheck // best effort - fmt.Printf("Retargeted %s to %s\n", branch, trunk) + fmt.Printf("%s Retargeted %s to %s\n", s.SuccessIcon(), s.Branch(branch), s.Branch(trunk)) // Update PR base on GitHub if PR exists prNum, _ := cfg.GetPR(branch) //nolint:errcheck // 0 is fine if prNum > 0 { if updateErr := gh.UpdatePRBase(prNum, trunk); updateErr != nil { - fmt.Printf("Warning: failed to update PR #%d base: %v\n", prNum, updateErr) + fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), prNum, updateErr) } else { - fmt.Printf("Updated PR #%d base to %s\n", prNum, trunk) + fmt.Printf("%s Updated PR #%d base to %s\n", s.SuccessIcon(), prNum, s.Branch(trunk)) } } } } else { - fmt.Printf("Run 'git config branch.%s.stackParent %s' to fix.\n", branch, trunk) + fmt.Println(s.Muted(fmt.Sprintf("Run 'git config branch.%s.stackParent %s' to fix.", branch, trunk))) } } } @@ -253,9 +256,9 @@ func runSync(cmd *cobra.Command, args []string) error { // Handle merged branch with interactive prompt if syncDryRunFlag { - fmt.Printf("Would handle merged branch %s\n", branch) + fmt.Printf("%s Would handle merged branch %s\n", s.Muted("dry-run:"), s.Branch(branch)) } else { - action := handleMergedBranch(g, cfg, branch, trunk, ¤tBranch) + action := handleMergedBranch(g, cfg, branch, trunk, ¤tBranch, s) if action == mergedActionSkip { // User chose to skip - don't collect fork points or retarget children continue @@ -270,7 +273,7 @@ func runSync(cmd *cobra.Command, args []string) error { // Fall back to calculating from parent (before it's deleted) forkPoint, fpErr = g.GetMergeBase(child.Name, branch) if fpErr != nil { - fmt.Printf("Warning: could not get fork point for %s: %v\n", child.Name, fpErr) + fmt.Printf("%s could not get fork point for %s: %v\n", s.WarningIcon(), s.Branch(child.Name), fpErr) forkPoint = "" // Will fall back to simple rebase } } @@ -286,17 +289,17 @@ func runSync(cmd *cobra.Command, args []string) error { // Retarget children to trunk for _, rt := range retargets { if syncDryRunFlag { - fmt.Printf("Would retarget %s to %s (fork point: %s)\n", rt.childName, trunk, rt.forkPoint) + fmt.Printf("%s Would retarget %s to %s (fork point: %s)\n", s.Muted("dry-run:"), s.Branch(rt.childName), s.Branch(trunk), rt.forkPoint) continue } - fmt.Printf("Retargeting %s to %s\n", rt.childName, trunk) + fmt.Printf("Retargeting %s to %s\n", s.Branch(rt.childName), s.Branch(trunk)) _ = cfg.SetParent(rt.childName, trunk) //nolint:errcheck // best effort // Update PR base on GitHub if rt.childPR > 0 { if updateErr := gh.UpdatePRBase(rt.childPR, trunk); updateErr != nil { - fmt.Printf("Warning: failed to update PR #%d base: %v\n", rt.childPR, updateErr) + fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), rt.childPR, updateErr) } } @@ -306,12 +309,12 @@ func runSync(cmd *cobra.Command, args []string) error { if len(displayForkPoint) > 8 { displayForkPoint = displayForkPoint[:8] } - fmt.Printf("Rebasing %s onto %s (from fork point %s)...\n", rt.childName, trunk, displayForkPoint) + fmt.Printf("Rebasing %s onto %s (from fork point %s)...\n", s.Branch(rt.childName), s.Branch(trunk), displayForkPoint) if rebaseErr := g.RebaseOnto(trunk, rt.forkPoint, rt.childName); rebaseErr != nil { - fmt.Printf("Warning: --onto rebase failed, will try normal cascade: %v\n", rebaseErr) + fmt.Printf("%s --onto rebase failed, will try normal cascade: %v\n", s.WarningIcon(), rebaseErr) // Don't return error - let cascade try } else { - fmt.Printf("Rebased %s successfully\n", rt.childName) + fmt.Printf("%s Rebased %s successfully\n", s.SuccessIcon(), s.Branch(rt.childName)) // Update fork point to new parent tip after successful rebase trunkTip, tipErr := g.GetTip(trunk) @@ -329,7 +332,7 @@ func runSync(cmd *cobra.Command, args []string) error { // Cascade all (if not disabled) if !syncNoCascadeFlag { - fmt.Println("\nCascading all branches...") + fmt.Println(s.Bold("\nCascading all branches...")) // Rebuild tree after modifications root, err = tree.Build(cfg) if err != nil { @@ -340,7 +343,7 @@ func runSync(cmd *cobra.Command, args []string) error { for _, child := range root.Children { allBranches := []*tree.Node{child} allBranches = append(allBranches, tree.GetDescendants(child)...) - if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef); err != nil { + if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s); err != nil { if err == ErrConflict { hitConflict = true } @@ -352,12 +355,12 @@ func runSync(cmd *cobra.Command, args []string) error { // Update stack comments on all PRs if !syncDryRunFlag { fmt.Println("\nUpdating stack comments...") - if err := updateStackComments(cfg, gh); err != nil { - fmt.Printf("Warning: failed to update some comments: %v\n", err) + if err := updateStackComments(cfg, gh, s); err != nil { + fmt.Printf("%s failed to update some comments: %v\n", s.WarningIcon(), err) } } - fmt.Println("\nSync complete!") + fmt.Println(s.SuccessMessage("\nSync complete!")) // Stash restoration handled by defer return nil } @@ -384,12 +387,12 @@ const ( // handleMergedBranch prompts the user for how to handle a merged branch and executes the choice. // Returns the action taken. If the user is on the merged branch, it will checkout trunk first. // The currentBranch pointer is updated if a checkout occurs. -func handleMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string) mergedAction { - fmt.Printf("\nBranch %q appears to be merged into %s.\n", branch, trunk) +func handleMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string, s *style.Style) mergedAction { + fmt.Printf("\nBranch %s appears to be %s into %s.\n", s.Branch(branch), s.Merged("merged"), s.Branch(trunk)) // Default to delete in non-interactive mode if !prompt.IsInteractive() { - return deleteMergedBranch(g, cfg, branch, trunk, currentBranch) + return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s) } // Interactive mode: prompt for action @@ -401,44 +404,44 @@ func handleMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, cu switch choice { case 0: // Delete - return deleteMergedBranch(g, cfg, branch, trunk, currentBranch) + return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s) case 1: // Orphan - return orphanMergedBranch(cfg, branch) + return orphanMergedBranch(cfg, branch, s) case 2: // Skip - fmt.Printf("Skipping %s (keeping in stack)\n", branch) + fmt.Printf("Skipping %s (keeping in stack)\n", s.Branch(branch)) return mergedActionSkip default: - return deleteMergedBranch(g, cfg, branch, trunk, currentBranch) + return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s) } } // deleteMergedBranch deletes a merged branch and removes it from stack config. // If the user is on the branch, it checks out trunk first. -func deleteMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string) mergedAction { +func deleteMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string, s *style.Style) mergedAction { // If user is on the merged branch, checkout trunk first if *currentBranch == branch { - fmt.Printf("Checking out %s (currently on merged branch)...\n", trunk) + fmt.Printf("Checking out %s (currently on merged branch)...\n", s.Branch(trunk)) if err := g.Checkout(trunk); err != nil { - fmt.Printf("Warning: could not checkout %s: %v\n", trunk, err) - fmt.Printf("Falling back to orphan instead of delete.\n") - return orphanMergedBranch(cfg, branch) + fmt.Printf("%s could not checkout %s: %v\n", s.WarningIcon(), s.Branch(trunk), err) + fmt.Println(s.Muted("Falling back to orphan instead of delete.")) + return orphanMergedBranch(cfg, branch, s) } *currentBranch = trunk } - fmt.Printf("Deleting merged branch %s\n", branch) + fmt.Printf("Deleting merged branch %s\n", s.Branch(branch)) _ = cfg.RemoveParent(branch) //nolint:errcheck // best effort cleanup _ = cfg.RemovePR(branch) //nolint:errcheck // best effort cleanup _ = cfg.RemoveForkPoint(branch) //nolint:errcheck // best effort cleanup if err := g.DeleteBranch(branch); err != nil { - fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err) + fmt.Printf("%s could not delete branch %s: %v\n", s.WarningIcon(), s.Branch(branch), err) } return mergedActionDelete } // orphanMergedBranch removes a branch from stack config but keeps the git branch. -func orphanMergedBranch(cfg *config.Config, branch string) mergedAction { - fmt.Printf("Orphaning %s (branch preserved, removed from stack)\n", branch) +func orphanMergedBranch(cfg *config.Config, branch string, s *style.Style) mergedAction { + fmt.Printf("Orphaning %s (branch preserved, removed from stack)\n", s.Branch(branch)) _ = cfg.RemoveParent(branch) //nolint:errcheck // best effort cleanup _ = cfg.RemovePR(branch) //nolint:errcheck // best effort cleanup _ = cfg.RemoveForkPoint(branch) //nolint:errcheck // best effort cleanup diff --git a/cmd/undo.go b/cmd/undo.go index eb3b148..5b6758f 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -10,6 +10,7 @@ import ( "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/undo" "github.com/spf13/cobra" ) @@ -41,6 +42,8 @@ func init() { } func runUndo(cmd *cobra.Command, args []string) error { + s := style.New() + cwd, err := os.Getwd() if err != nil { return err @@ -63,34 +66,34 @@ func runUndo(cmd *cobra.Command, args []string) error { snapshot, snapshotPath, err := undo.LoadLatest(gitDir) if err != nil { if errors.Is(err, undo.ErrNoSnapshot) { - fmt.Println("Nothing to undo.") + fmt.Println(s.Muted("Nothing to undo.")) return nil } return fmt.Errorf("failed to load undo state: %w", err) } // Display what will be restored - fmt.Printf("About to undo '%s' from %s\n\n", snapshot.Command, snapshot.Timestamp.Local().Format("2006-01-02 15:04:05")) + fmt.Printf("About to undo '%s' from %s\n\n", s.Bold(snapshot.Command), snapshot.Timestamp.Local().Format("2006-01-02 15:04:05")) fmt.Println("This will restore:") // Show branch changes for name, bs := range snapshot.Branches { currentSHA, tipErr := g.GetTip(name) if tipErr != nil { - fmt.Printf(" - %s: (branch missing) → %s\n", name, git.AbbrevSHA(bs.SHA)) + fmt.Printf(" - %s: %s → %s\n", s.Branch(name), s.Muted("(branch missing)"), git.AbbrevSHA(bs.SHA)) } else if currentSHA != bs.SHA { - fmt.Printf(" - %s: %s → %s\n", name, git.AbbrevSHA(currentSHA), git.AbbrevSHA(bs.SHA)) + fmt.Printf(" - %s: %s → %s\n", s.Branch(name), git.AbbrevSHA(currentSHA), git.AbbrevSHA(bs.SHA)) } else { - fmt.Printf(" - %s: (unchanged)\n", name) + fmt.Printf(" - %s: %s\n", s.Branch(name), s.Muted("(unchanged)")) } } // Show deleted branches to recreate for name, bs := range snapshot.DeletedBranches { if g.BranchExists(name) { - fmt.Printf(" - %s: (already exists, will skip)\n", name) + fmt.Printf(" - %s: %s\n", s.Branch(name), s.Muted("(already exists, will skip)")) } else { - fmt.Printf(" - Recreate deleted branch: %s at %s\n", name, git.AbbrevSHA(bs.SHA)) + fmt.Printf(" - Recreate deleted branch: %s at %s\n", s.Branch(name), git.AbbrevSHA(bs.SHA)) } } @@ -103,7 +106,7 @@ func runUndo(cmd *cobra.Command, args []string) error { // Dry run exits here if undoDryRun { - fmt.Println("Dry run: no changes made.") + fmt.Println(s.Muted("Dry run: no changes made.")) return nil } @@ -114,7 +117,7 @@ func runUndo(cmd *cobra.Command, args []string) error { return confirmErr } if !confirmed { - fmt.Println("Undo cancelled.") + fmt.Println(s.Muted("Undo cancelled.")) return nil } } @@ -150,7 +153,7 @@ func runUndo(cmd *cobra.Command, args []string) error { currentSHA, err := g.GetTip(name) if err != nil { // Branch doesn't exist, create it (can be done while checked out) - fmt.Printf(" Creating %s at %s\n", name, git.AbbrevSHA(bs.SHA)) + fmt.Printf(" Creating %s at %s\n", s.Branch(name), git.AbbrevSHA(bs.SHA)) if createErr := g.CreateBranchAt(name, bs.SHA); createErr != nil { return fmt.Errorf("failed to create branch %s: %w", name, createErr) } @@ -160,7 +163,7 @@ func runUndo(cmd *cobra.Command, args []string) error { // Restore config if configErr := restoreBranchConfig(cfg, name, bs); configErr != nil { - fmt.Printf(" Warning: failed to restore config for %s: %v\n", name, configErr) + fmt.Printf(" %s failed to restore config for %s: %v\n", s.WarningIcon(), s.Branch(name), configErr) } } @@ -188,7 +191,7 @@ func runUndo(cmd *cobra.Command, args []string) error { // Now reset all branches that need it for name, bs := range branchesToReset { - fmt.Printf(" Resetting %s to %s\n", name, git.AbbrevSHA(bs.SHA)) + fmt.Printf(" Resetting %s to %s\n", s.Branch(name), git.AbbrevSHA(bs.SHA)) if resetErr := g.SetBranchRef(name, bs.SHA); resetErr != nil { return fmt.Errorf("failed to reset branch %s: %w", name, resetErr) } @@ -197,25 +200,25 @@ func runUndo(cmd *cobra.Command, args []string) error { // Recreate deleted branches for name, bs := range snapshot.DeletedBranches { if g.BranchExists(name) { - fmt.Printf(" Skipping %s (already exists)\n", name) + fmt.Printf(" Skipping %s %s\n", s.Branch(name), s.Muted("(already exists)")) continue } - fmt.Printf(" Recreating %s at %s\n", name, git.AbbrevSHA(bs.SHA)) + fmt.Printf(" Recreating %s at %s\n", s.Branch(name), git.AbbrevSHA(bs.SHA)) if createErr := g.CreateBranchAt(name, bs.SHA); createErr != nil { return fmt.Errorf("failed to recreate branch %s: %w", name, createErr) } // Restore config for deleted branch if configErr := restoreBranchConfig(cfg, name, bs); configErr != nil { - fmt.Printf(" Warning: failed to restore config for %s: %v\n", name, configErr) + fmt.Printf(" %s failed to restore config for %s: %v\n", s.WarningIcon(), s.Branch(name), configErr) } } // Checkout original HEAD if snapshot.OriginalHead != "" { - fmt.Printf(" Checking out %s\n", snapshot.OriginalHead) + fmt.Printf(" Checking out %s\n", s.Branch(snapshot.OriginalHead)) if checkoutErr := g.Checkout(snapshot.OriginalHead); checkoutErr != nil { - fmt.Printf(" Warning: failed to checkout %s: %v\n", snapshot.OriginalHead, checkoutErr) + fmt.Printf(" %s failed to checkout %s: %v\n", s.WarningIcon(), s.Branch(snapshot.OriginalHead), checkoutErr) } } @@ -223,16 +226,16 @@ func runUndo(cmd *cobra.Command, args []string) error { if snapshot.StashRef != "" { fmt.Println(" Restoring stashed changes...") if err := g.StashPop(snapshot.StashRef); err != nil { - fmt.Printf(" Warning: could not cleanly restore stashed changes. Your changes are still in stash (commit %s).\n", git.AbbrevSHA(snapshot.StashRef)) + fmt.Printf(" %s could not cleanly restore stashed changes. Your changes are still in stash (commit %s).\n", s.WarningIcon(), git.AbbrevSHA(snapshot.StashRef)) } } // Archive the snapshot if err := undo.Archive(gitDir, snapshotPath); err != nil { - fmt.Printf("Warning: failed to archive undo state: %v\n", err) + fmt.Printf("%s failed to archive undo state: %v\n", s.WarningIcon(), err) } - fmt.Printf("\nUndo complete. Restored state from before '%s'.\n", snapshot.Command) + fmt.Printf("\n%s Restored state from before '%s'.\n", s.SuccessIcon(), s.Bold(snapshot.Command)) return nil } diff --git a/cmd/unlink.go b/cmd/unlink.go index 7ebc5b9..e5e6194 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -7,6 +7,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/spf13/cobra" ) @@ -42,6 +43,7 @@ func runUnlink(cmd *cobra.Command, args []string) error { return err } - fmt.Printf("Unlinked PR from branch %q\n", branch) + s := style.New() + fmt.Printf("%s Unlinked PR from branch %s\n", s.SuccessIcon(), s.Branch(branch)) return nil } diff --git a/internal/style/style.go b/internal/style/style.go new file mode 100644 index 0000000..dd76f86 --- /dev/null +++ b/internal/style/style.go @@ -0,0 +1,193 @@ +// Package style provides consistent terminal output styling for gh-stack. +// It follows GitHub CLI color conventions and respects NO_COLOR and TTY detection. +package style + +import ( + "fmt" + "os" + "sync" + + "github.com/cli/go-gh/v2/pkg/term" + "github.com/mgutz/ansi" +) + +// Style provides methods for styling terminal output. +// All methods return plain text when colors are disabled. +type Style struct { + enabled bool +} + +var ( + // Color functions using basic ANSI colors for maximum compatibility. + green = ansi.ColorFunc("green") + red = ansi.ColorFunc("red") + yellow = ansi.ColorFunc("yellow") + cyan = ansi.ColorFunc("cyan") + magenta = ansi.ColorFunc("magenta") + gray = ansi.ColorFunc("black+h") // bright black = gray + bold = ansi.ColorFunc("default+b") + + // Cached terminal state. + termState term.Term + termStateOnce sync.Once +) + +func getTermState() term.Term { + termStateOnce.Do(func() { + termState = term.FromEnv() + }) + return termState +} + +// isColorEnabled returns true if color output should be used. +// Respects NO_COLOR, CLICOLOR, CLICOLOR_FORCE, and GH_FORCE_TTY. +func isColorEnabled() bool { + // NO_COLOR takes precedence (https://no-color.org/) + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + + // Use go-gh's term package which respects GH_FORCE_TTY, CLICOLOR, etc. + return getTermState().IsColorEnabled() +} + +// New creates a new Style instance. +// Colors are automatically enabled/disabled based on terminal capabilities. +func New() *Style { + return &Style{enabled: isColorEnabled()} +} + +// NewWithColor creates a Style with explicit color setting. +// Useful for testing or forcing color on/off. +func NewWithColor(enabled bool) *Style { + return &Style{enabled: enabled} +} + +// Enabled returns whether colors are enabled. +func (s *Style) Enabled() bool { + return s.enabled +} + +// Success styles text for success messages (green). +func (s *Style) Success(t string) string { + if !s.enabled { + return t + } + return green(t) +} + +// Successf styles formatted text for success messages (green). +func (s *Style) Successf(format string, args ...interface{}) string { + return s.Success(fmt.Sprintf(format, args...)) +} + +// Warning styles text for warning messages (yellow). +func (s *Style) Warning(t string) string { + if !s.enabled { + return t + } + return yellow(t) +} + +// Warningf styles formatted text for warning messages (yellow). +func (s *Style) Warningf(format string, args ...interface{}) string { + return s.Warning(fmt.Sprintf(format, args...)) +} + +// Error styles text for error messages (red). +func (s *Style) Error(t string) string { + if !s.enabled { + return t + } + return red(t) +} + +// Errorf styles formatted text for error messages (red). +func (s *Style) Errorf(format string, args ...interface{}) string { + return s.Error(fmt.Sprintf(format, args...)) +} + +// Branch styles branch names (cyan). +func (s *Style) Branch(t string) string { + if !s.enabled { + return t + } + return cyan(t) +} + +// Branchf styles formatted branch names (cyan). +func (s *Style) Branchf(format string, args ...interface{}) string { + return s.Branch(fmt.Sprintf(format, args...)) +} + +// Merged styles merged PR references (magenta). +func (s *Style) Merged(t string) string { + if !s.enabled { + return t + } + return magenta(t) +} + +// Mergedf styles formatted merged PR references (magenta). +func (s *Style) Mergedf(format string, args ...interface{}) string { + return s.Merged(fmt.Sprintf(format, args...)) +} + +// Muted styles secondary/hint text (gray). +func (s *Style) Muted(t string) string { + if !s.enabled { + return t + } + return gray(t) +} + +// Mutedf styles formatted secondary/hint text (gray). +func (s *Style) Mutedf(format string, args ...interface{}) string { + return s.Muted(fmt.Sprintf(format, args...)) +} + +// Bold styles text with bold weight. +func (s *Style) Bold(t string) string { + if !s.enabled { + return t + } + return bold(t) +} + +// Boldf styles formatted text with bold weight. +func (s *Style) Boldf(format string, args ...interface{}) string { + return s.Bold(fmt.Sprintf(format, args...)) +} + +// SuccessIcon returns the success icon (✓) in green. +func (s *Style) SuccessIcon() string { + return s.Success("✓") +} + +// WarningIcon returns the warning icon (!) in yellow. +func (s *Style) WarningIcon() string { + return s.Warning("!") +} + +// FailureIcon returns the failure icon (✗) in red. +func (s *Style) FailureIcon() string { + return s.Error("✗") +} + +// SuccessMessage formats a complete success message with icon. +// Example: "✓ Operation complete" +func (s *Style) SuccessMessage(msg string) string { + return fmt.Sprintf("%s %s", s.SuccessIcon(), s.Success(msg)) +} + +// WarningMessage formats a complete warning message with icon. +// Example: "! Something might be wrong" +func (s *Style) WarningMessage(msg string) string { + return fmt.Sprintf("%s %s", s.WarningIcon(), s.Warning(msg)) +} + +// FailureMessage formats a complete failure message with icon. +// Example: "✗ Operation failed" +func (s *Style) FailureMessage(msg string) string { + return fmt.Sprintf("%s %s", s.FailureIcon(), s.Error(msg)) +} diff --git a/internal/tree/tree.go b/internal/tree/tree.go index ea54cd3..9bc5600 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/style" ) // Node represents a branch in the stack tree. @@ -111,6 +112,8 @@ type FormatOptions struct { CurrentBranch string // PRURLFunc returns the URL for a PR number (optional) PRURLFunc func(pr int) string + // Style is used for coloring output (optional, defaults to no color) + Style *style.Style } // FormatTree returns a string representation of the tree with box-drawing characters. @@ -131,10 +134,21 @@ func formatNode(sb *strings.Builder, node *Node, prefix string, isLast bool, opt connector = "" } + // Format branch name with optional styling + branchDisplay := node.Name + if opts.Style != nil { + branchDisplay = opts.Style.Branch(node.Name) + } + // Current branch marker marker := "" if node.Name == opts.CurrentBranch { marker = "* " + // Bold the current branch marker and name + if opts.Style != nil { + branchDisplay = opts.Style.Bold("* " + node.Name) + marker = "" + } } // PR info @@ -150,7 +164,7 @@ func formatNode(sb *strings.Builder, node *Node, prefix string, isLast bool, opt sb.WriteString(prefix) sb.WriteString(connector) sb.WriteString(marker) - sb.WriteString(node.Name) + sb.WriteString(branchDisplay) sb.WriteString(prInfo) sb.WriteString("\n") From 98b0865f0ba5b96b9a9629060b14f7dc784a945c Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 6 Feb 2026 13:09:27 -0800 Subject: [PATCH 2/2] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass style instance to saveUndoSnapshotByName instead of creating new one - Fix SuccessMessage newline placement in sync command - Remove undocumented ℹ icon from init command Co-authored-by: Cursor --- cmd/cascade.go | 3 +-- cmd/init.go | 2 +- cmd/sync.go | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/cascade.go b/cmd/cascade.go index e1efa4c..d57f742 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -281,8 +281,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del // saveUndoSnapshotByName is like saveUndoSnapshot but takes branch names instead of tree nodes. // Useful for sync where we don't always have tree nodes. // Returns the stash ref (commit hash) if changes were stashed, empty string otherwise. -func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string) (string, error) { - s := style.New() +func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string, s *style.Style) (string, error) { gitDir := g.GetGitDir() // Get current branch for original head diff --git a/cmd/init.go b/cmd/init.go index f0b1c55..3664d35 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -60,7 +60,7 @@ func runInit(cmd *cobra.Command, args []string) error { // Check if already initialized if existing, err := cfg.GetTrunk(); err == nil { - fmt.Printf("%s Already initialized with trunk %s\n", s.Muted("ℹ"), s.Branch(existing)) + fmt.Printf("Already initialized with trunk %s\n", s.Branch(existing)) return nil } diff --git a/cmd/sync.go b/cmd/sync.go index 23cb12b..1284bf6 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -109,7 +109,7 @@ func runSync(cmd *cobra.Command, args []string) error { allBranches, listErr := cfg.ListTrackedBranches() if listErr == nil && len(allBranches) > 0 { var saveErr error - stashRef, saveErr = saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync") + stashRef, saveErr = saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync", s) if saveErr != nil { fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr) } @@ -360,7 +360,8 @@ func runSync(cmd *cobra.Command, args []string) error { } } - fmt.Println(s.SuccessMessage("\nSync complete!")) + fmt.Println() + fmt.Println(s.SuccessMessage("Sync complete!")) // Stash restoration handled by defer return nil }