Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .claude/rules/colors.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions cmd/abort.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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
}
4 changes: 3 additions & 1 deletion cmd/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
43 changes: 23 additions & 20 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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"))
}
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -278,7 +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) {
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
Expand All @@ -304,15 +307,15 @@ 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"))
}
}

// Capture state of branches that will be modified
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
Expand All @@ -322,7 +325,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
Expand Down
17 changes: 10 additions & 7 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
7 changes: 5 additions & 2 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Loading