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
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
- `internal/stack/`: Shared catalog (`<common-dir>/gh-stack`, JSON), conservative legacy migration, atomic saves, and short catalog locks.
- `internal/worktree/`: Origin/owner identities, cleanliness preflight, scoped operations, and touched-ref recovery.
- `internal/theme/`: GitHub-branded palette (single source of all hex colors), plain-output colorizers, and `CanvasOutput`, which paints GitHub's canvas behind full-screen TUIs.
- `internal/tui/`: bubbletea views (`stackview`, `modifyview`).

## Coding conventions
Expand All @@ -42,5 +43,6 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
- Finish paused operations before switching preview stages or versions. Reject origin-only and unknown rebase execution modes before routing recovery; use the matching layer3 build in the recorded origin for origin-only journals.
- Mutation locks coordinate gh-stack only, not Git commands/editors. Keep affected worktrees quiescent during rewrites. Pass the snapshot SHA (or prior `Context.Touched` SHA) to `Context.Start` before ref mutations; never claim an external commit as this operation's work during continuation.
- Distributed modify preflights action/cascade targets and executes in recorded owners; only the origin may switch for unoccupied branches. Preserve dropped/folded source worktrees and refs. Before native continuation, preflight other worktrees, not remaining branches in the intentionally busy pending worktree.
- Colors come only from `internal/theme`. Run full-screen TUIs through `runFullScreenTUI` (canvas painting). Commands launched with `tea.ExecProcess` must set their own `Stdout`.

For full architecture details, see [AGENTS.md](../AGENTS.md) in the repository root.
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ internal/
modify/ # interactive stack modification state machine
worktree/ # operation-owned worktree identities and ref recovery
pr/ # PR template discovery
theme/ # GitHub-branded color palette, colorizers, full-screen canvas painting
tui/ # bubbletea/bubbles/lipgloss terminal UI
stackview/ # interactive stack visualization
modifyview/ # interactive modify session UI
Expand Down Expand Up @@ -147,3 +148,5 @@ if errors.As(err, &exitErr) { ... }
- Rebase commands disable automatic maintenance through command-local configuration so detached `rerere gc` cannot race conflict handling. Repository settings and unrelated Git commands are unchanged.
- Date-preserving rebase starts use the merge backend so Git persists the date setting across conflicts. Continuations use native saved settings, not start-only date flags.
- The `.gitignore` ignores `/gh-stack` and `/gh-stack.exe` (the built binary).
- Colors: every hex value lives in `internal/theme` (Primer Brand hues on GitHub neutrals, as `lipgloss.AdaptiveColor` light/dark pairs; TUIs use the `internal/tui/shared` aliases). Don't hard-code colors in views. `TestPaletteContrast` enforces WCAG contrast on the GitHub canvases.
- Full-screen TUIs (`view`, `modify`, `submit`) run through `runFullScreenTUI` (`cmd/utils.go`), which paints GitHub's canvas via `theme.CanvasOutput` when the terminal is a truecolor TTY and `GH_STACK_BACKGROUND` isn't `terminal`. The wrapper paints right after the alternate screen is entered and restores colors right before it's left, which covers exits, panics, signals, and `tea.ExecProcess`. Before the program starts, `planRestore` probes the terminal (OSC 11/10 queries) and plans each color's restore. It uses a reset (OSC 111/110) when that brings the color back, so configuration changes made during the session still apply. Runtime-set colors, and terminals without reset support, get an explicit set. Never query the terminal while Bubble Tea owns its input. Commands run with `tea.ExecProcess` must set their own `Stdout`, or the child gets the non-terminal wrapper (see `editorCommand`). Inline TUIs (`checkout`, `merge`) and plain output never paint.
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -749,21 +749,37 @@ Compared to the typical workflow, there's no need to name branches, run `git add

## Terminal theme

The interactive screens (`submit`, `modify`, and `view`) and all colored command output (status messages, prompts) automatically adapt their colors to your terminal's background, so they're readable on both dark and light themes. The background is detected from the terminal; if a terminal doesn't report it (some SSH or `tmux` setups), the dark palette is used.
gh-stack uses GitHub's colors: the Primer Brand blues, greens, and status colors, on GitHub's own dark or light canvas.

The full-screen screens (`submit`, `modify`, and `view`) paint GitHub's canvas behind themselves, like the GitHub Copilot CLI: a `#0d1117` background on dark terminals and a `#ffffff` background on light ones. gh-stack restores your terminal's colors when the screen closes and while `$EDITOR` is open. Terminals that report their colors also get back colors set at runtime (for example by pywal or base16-shell); others return to their configured colors. Painting needs a truecolor terminal (most modern terminals, including iTerm2, Ghostty, kitty, WezTerm, Windows Terminal, and VS Code); other terminals keep their own background. In `tmux`, only the current pane is painted.

All other output (status messages, prompts, and the inline `checkout` and `merge` screens) keeps your terminal's background. Colors everywhere adapt to whether that background is dark or light. The background is detected from the terminal; if a terminal doesn't report it (some SSH or `tmux` setups), the dark palette is used.

Set `GH_STACK_THEME` to force a palette if detection is wrong:

| Value | Behavior |
|-------|----------|
| `auto` (default) | Detect from the terminal background |
| `light` | Force the light palette |
| `dark` | Force the dark palette |
| `light` | Force the light palette (and the light canvas) |
| `dark` | Force the dark palette (and the dark canvas) |

Set `GH_STACK_BACKGROUND` to control the canvas:

| Value | Behavior |
|-------|----------|
| `github` (default) | Paint GitHub's canvas behind the full-screen screens |
| `terminal` | Keep your terminal's background and text colors everywhere |

```bash
# Force the light palette
export GH_STACK_THEME=light && gh stack view

# Keep your terminal's own background in the full-screen screens
export GH_STACK_BACKGROUND=terminal
```

If gh-stack is force-killed (for example with `kill -9`) while a full-screen screen is open, the terminal can't be restored automatically. Run `printf '\033]111\007\033]110\007'` or open a new tab to reset its colors.

## Exit codes

| Code | Meaning |
Expand Down
8 changes: 4 additions & 4 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {

if s.IsFullyMerged() {
cfg.Warningf("All branches in this stack have been merged")
cfg.Printf("Consider creating a new stack with `%s`", cfg.ColorCyan("gh stack init"))
cfg.Printf("Consider creating a new stack with `%s`", cfg.ColorAccent("gh stack init"))
return nil
}

idx := s.IndexOf(currentBranch)
// idx < 0 means we're on the trunk — that's allowed (we'll create
// a new branch from it). Only block if we're in the middle of the stack.
if idx >= 0 && idx < len(s.Branches)-1 {
cfg.Errorf("can only add branches to the top of the stack; run `%s` then `%s`", cfg.ColorCyan("gh stack top"), cfg.ColorCyan("gh stack add"))
cfg.Printf("Or to restructure your stack and insert a branch, use `%s`", cfg.ColorCyan("gh stack modify"))
cfg.Errorf("can only add branches to the top of the stack; run `%s` then `%s`", cfg.ColorAccent("gh stack top"), cfg.ColorAccent("gh stack add"))
cfg.Printf("Or to restructure your stack and insert a branch, use `%s`", cfg.ColorAccent("gh stack modify"))
return ErrInvalidArgs
}

Expand Down Expand Up @@ -130,7 +130,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
}
cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), currentBranch)
cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch)
cfg.Printf("When you're ready for the next layer, run `%s` again", cfg.ColorCyan("gh stack add"))
cfg.Printf("When you're ready for the next layer, run `%s` again", cfg.ColorAccent("gh stack add"))
return nil
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,14 @@ func runAlias(cfg *config.Config, name string, binDir string) error {
// Check for an existing command with this name.
if existing, err := exec.LookPath(name); err == nil {
cfg.Errorf("a command named %q already exists at %s", name, existing)
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst"))
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorAccent("gh stack alias gst"))
return ErrInvalidArgs
}

// Guard against overwriting an existing file that isn't on PATH
if _, err := os.Stat(scriptPath); err == nil {
cfg.Errorf("a file already exists at %s", scriptPath)
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorCyan("gh stack alias gst"))
cfg.Printf("Choose a different alias name, for example: %s", cfg.ColorAccent("gh stack alias gst"))
return ErrInvalidArgs
}

Expand All @@ -130,7 +130,7 @@ func runAlias(cfg *config.Config, name string, binDir string) error {
}

cfg.Successf("Created alias %q at %s", name, scriptPath)
cfg.Printf("You can now use %s instead of %s", cfg.ColorCyan(name+" <command>"), cfg.ColorCyan("gh stack <command>"))
cfg.Printf("You can now use %s instead of %s", cfg.ColorAccent(name+" <command>"), cfg.ColorAccent("gh stack <command>"))

// Warn if the bin directory is not in PATH.
if !dirInPath(binDir) {
Expand Down
14 changes: 7 additions & 7 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
if errors.Is(err, errRemoteBranchNotFound) {
cfg.Errorf("no local or remote stack found for %q", opts.target)
cfg.Printf("Try a stack or PR number with `%s`",
cfg.ColorCyan("gh stack checkout <number>"))
cfg.ColorAccent("gh stack checkout <number>"))
return ErrNotInStack
}
if err != nil {
Expand Down Expand Up @@ -181,7 +181,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
cfg.Successf("Switched to %s", targetBranch)
cfg.Printf("Stack: %s", s.DisplayChain())
cfg.Printf("Run `%s` to see the full stack",
cfg.ColorCyan("gh stack view"))
cfg.ColorAccent("gh stack view"))
return nil
}

Expand Down Expand Up @@ -297,7 +297,7 @@ func checkoutRemoteStackByBranch(cfg *config.Config, sf *stack.StackFile, gitDir
cfg.Errorf("branch %q belongs to multiple stacks on GitHub (%s)",
branch, strings.Join(stackNumbers, ", "))
cfg.Printf("Use `%s` with a stack or PR number to choose one",
cfg.ColorCyan("gh stack checkout <number>"))
cfg.ColorAccent("gh stack checkout <number>"))
return nil, "", ErrDisambiguate
}

Expand Down Expand Up @@ -424,7 +424,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps,
}
if allMerged {
cfg.Infof("All PRs in this stack have been merged")
cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init"))
cfg.Printf("To start a new stack, use `%s`", cfg.ColorAccent("gh stack init"))
return nil, "", ErrSilent
}

Expand Down Expand Up @@ -566,7 +566,7 @@ func handleCompositionConflict(
}
cfg.Printf(" Remote: (%s) <- %s", trunk, strings.Join(remoteBranches, " <- "))
cfg.Printf(" Unstack on remote or use `%s` to unstack locally",
cfg.ColorCyan("gh stack unstack --local"))
cfg.ColorAccent("gh stack unstack --local"))
return nil, ErrConflict
}

Expand Down Expand Up @@ -758,8 +758,8 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
if len(rows) == 0 {
cfg.Infof("No stacks available to check out")
cfg.Printf("Create a stack with `%s` or check out a stack by number with `%s`",
cfg.ColorCyan("gh stack init"),
cfg.ColorCyan("gh stack checkout <number>"))
cfg.ColorAccent("gh stack init"),
cfg.ColorAccent("gh stack checkout <number>"))
return nil, "", nil
}

Expand Down
16 changes: 8 additions & 8 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func runInit(cfg *config.Config, opts *initOptions) error {
if opts.adopt {
cfg.Warningf("The --adopt flag is deprecated. Existing branches are now adopted automatically.")
cfg.Printf("You can simply run: %s",
cfg.ColorCyan("gh stack init <branch1> <branch2> ..."))
cfg.ColorAccent("gh stack init <branch1> <branch2> ..."))
}

// --- Branch collection ---
Expand Down Expand Up @@ -327,7 +327,7 @@ func runInteractiveInitWithPreflight(cfg *config.Config, sf *stack.StackFile, tr

cfg.Printf("Initializing a stack from %s.", trunk)
cfg.Printf("Have multiple branches already? Run: %s",
cfg.ColorCyan("gh stack init <branch1> <branch2> ..."))
cfg.ColorAccent("gh stack init <branch1> <branch2> ..."))
cfg.Printf("")

var branchName string
Expand Down Expand Up @@ -467,12 +467,12 @@ func printWhatsNextInWorktree(cfg *config.Config, s *stack.Stack, branches []str
cfg.Printf("")
cfg.Printf("What's next:")
if hasAdopted {
cfg.Printf(" • see the full stack: %s", cfg.ColorCyan("gh stack view"))
cfg.Printf(" • move between branches: %s", cfg.ColorCyan("gh stack switch"))
cfg.Printf(" • link these PRs into a Stack on GitHub: %s", cfg.ColorCyan("gh stack submit"))
cfg.Printf(" • see the full stack: %s", cfg.ColorAccent("gh stack view"))
cfg.Printf(" • move between branches: %s", cfg.ColorAccent("gh stack switch"))
cfg.Printf(" • link these PRs into a Stack on GitHub: %s", cfg.ColorAccent("gh stack submit"))
} else {
cfg.Printf(" • commit your work as usual, then add a layer: %s", cfg.ColorCyan("gh stack add"))
cfg.Printf(" • see your stack any time: %s", cfg.ColorCyan("gh stack view"))
cfg.Printf(" • when ready to open PRs: %s", cfg.ColorCyan("gh stack submit"))
cfg.Printf(" • commit your work as usual, then add a layer: %s", cfg.ColorAccent("gh stack add"))
cfg.Printf(" • see your stack any time: %s", cfg.ColorAccent("gh stack view"))
cfg.Printf(" • when ready to open PRs: %s", cfg.ColorAccent("gh stack submit"))
}
}
6 changes: 3 additions & 3 deletions cmd/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error {
if blocker != nil {
top := candidates[len(candidates)-1].Number
cfg.Errorf("cannot merge the whole stack: pull request #%d is %s", blocker.Number, blockerState(blocker))
cfg.Printf("Merge up to #%d with `%s`", top, cfg.ColorCyan(fmt.Sprintf("gh stack merge %d", top)))
cfg.Printf("Merge up to #%d with `%s`", top, cfg.ColorAccent(fmt.Sprintf("gh stack merge %d", top)))
return ErrInvalidArgs
}
targetPR = candidates[len(candidates)-1].Number
Expand Down Expand Up @@ -264,12 +264,12 @@ func resolveActiveRemoteStack(cfg *config.Config, client github.ClientOps) (*git
stacks := sf.FindAllStacksForBranch(currentBranch)
if len(stacks) == 0 {
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorCyan("gh stack merge [number]"))
cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorAccent("gh stack merge [number]"))
return nil, ErrNotInStack
}
if len(stacks) > 1 {
cfg.Errorf("branch %q belongs to multiple stacks", currentBranch)
cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorCyan("gh stack merge [number]"))
cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorAccent("gh stack merge [number]"))
return nil, ErrDisambiguate
}
s := stacks[0]
Expand Down
32 changes: 13 additions & 19 deletions cmd/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func runModify(cfg *config.Config) error {
// before opening the TUI, mirroring submit's "nothing to submit" behavior.
if s.IsFullyMerged() {
cfg.Warningf("All branches in this stack have been merged")
cfg.Printf("There's nothing to modify — start a new stack with `%s`", cfg.ColorCyan("gh stack init"))
cfg.Printf("There's nothing to modify — start a new stack with `%s`", cfg.ColorAccent("gh stack init"))
return nil
}

Expand All @@ -117,13 +117,7 @@ func runModify(cfg *config.Config) error {
// Run the TUI
model := modifyview.New(modifyNodes, s.Trunk, Version)

p := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithMouseAllMotion(),
)

finalModel, err := p.Run()
finalModel, err := runFullScreenTUI(cfg, model, tea.WithMouseAllMotion())
if err != nil {
return fmt.Errorf("running TUI: %w", err)
}
Expand Down Expand Up @@ -177,7 +171,7 @@ func runModify(cfg *config.Config) error {
cfg.Printf("")

cfg.Printf("Or restore the stack to its pre-modify state with `%s`",
cfg.ColorCyan("gh stack modify --abort"))
cfg.ColorAccent("gh stack modify --abort"))
return ErrConflict
}

Expand Down Expand Up @@ -211,7 +205,7 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) {

for _, d := range result.DroppedPRs {
cfg.Printf(" Dropped: %s (PR #%d remains open — close with `%s`)",
d.Branch, d.PRNumber, cfg.ColorCyan(fmt.Sprintf("gh pr close %d", d.PRNumber)))
d.Branch, d.PRNumber, cfg.ColorAccent(fmt.Sprintf("gh pr close %d", d.PRNumber)))
}

if result.MovedBranches > 0 {
Expand All @@ -222,7 +216,7 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) {
cfg.Printf("")
if result.NeedsSubmit {
cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub",
cfg.ColorCyan("gh stack submit"))
cfg.ColorAccent("gh stack submit"))
}
}

Expand Down Expand Up @@ -260,7 +254,7 @@ func runModifyAbort(cfg *config.Config) error {
if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil {
cfg.Errorf("recovery failed: %s", err)
cfg.Printf("Recovery state was retained. Resolve the reported problem and retry `%s`.",
cfg.ColorCyan("gh stack modify --abort"))
cfg.ColorAccent("gh stack modify --abort"))
return ErrSilent
}
cfg.Successf("Stack restored successfully")
Expand All @@ -269,7 +263,7 @@ func runModifyAbort(cfg *config.Config) error {
case modify.PhasePendingSubmit:
cfg.Printf("A modify completed but the stack has not been submitted")
cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub",
cfg.ColorCyan("gh stack submit"))
cfg.ColorAccent("gh stack submit"))
return nil

default:
Expand Down Expand Up @@ -333,8 +327,8 @@ func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) {
if inProgress {
cfg.Errorf("a rebase is currently in progress")
cfg.Printf("Complete the rebase with `%s` or abort with `%s`",
cfg.ColorCyan("gh stack rebase --continue"),
cfg.ColorCyan("gh stack rebase --abort"))
cfg.ColorAccent("gh stack rebase --continue"),
cfg.ColorAccent("gh stack rebase --abort"))
return nil, ErrRebaseActive
}

Expand Down Expand Up @@ -409,18 +403,18 @@ func checkNoModifyInProgress(cfg *config.Config, gitDir string) error {
case modify.PhaseApplying:
cfg.Errorf("a previous modify session was interrupted")
cfg.Printf("Run `%s` to restore your stack",
cfg.ColorCyan("gh stack modify --abort"))
cfg.ColorAccent("gh stack modify --abort"))
return ErrModifyRecovery
case modify.PhaseConflict:
cfg.Errorf("a modify has unresolved conflicts")
cfg.Printf("Run `%s` to continue, or `%s` to restore your stack",
cfg.ColorCyan("gh stack modify --continue"),
cfg.ColorCyan("gh stack modify --abort"))
cfg.ColorAccent("gh stack modify --continue"),
cfg.ColorAccent("gh stack modify --abort"))
return ErrSilent
case modify.PhasePendingSubmit:
cfg.Errorf("a modify was completed but the stack has not been submitted yet")
cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub",
cfg.ColorCyan("gh stack submit"))
cfg.ColorAccent("gh stack submit"))
return ErrSilent
default:
cfg.Errorf("unexpected modify state phase: %s", state.Phase)
Expand Down
Loading
Loading