Skip to content

Conversation

@josephschmitt
Copy link

@josephschmitt josephschmitt commented Jan 21, 2026

PR Description


A note on scope

I want to acknowledge upfront that this PR introduces an external integration with https://github.com/abhinav/git-spice, a tool that extends Git with stacked branch workflows. This isn't core Git functionality, and I completely understand if that puts it outside the scope of what lazygit wants to support. If you decide this doesn't belong in this project, I'll be disappointed, but understand completely.

That said, after using git-spice alongside lazygit for the past year, I found myself constantly context-switching between the terminal and lazygit. The experience of managing stacked PRs felt incomplete. I looked for a plugin system to contribute this without touching the main codebase, but couldn't find one: so here I am with a PR instead.

I also want to acknowledge that I've implemented support for git-spice specifically, rather than building a generic "stacked branch tool" abstraction. This was a practical decision: git-spice is the tool I know and use daily, and trying to abstract across multiple stacking tools (graphite, ghstack, etc.) would have significantly increased complexity without even knowing if you'd accept this feature. If maintainers feel a more generic approach would be preferable, I'm open to discussing that.


What is git-spice?

https://github.com/abhinav/git-spice is a CLI tool that helps manage stacked pull requests, a workflow where you break large changes into a chain of dependent, reviewable PRs:

 main
  └── feat/auth-base (#101)
       └── feat/auth-api (#102)
            └── feat/auth-ui (#103)

Instead of one massive 2000-line PR, you get three focused, reviewable PRs that build on each other. git-spice handles the complexity of:

  • Keeping branches rebased on their parents
  • Submitting/updating PRs for the entire stack
  • Navigating between branches in the stack
  • Syncing with remote and cleaning up merged branches

This workflow is increasingly popular (see Graphite, ghstack, Stacked Git) because it leads to faster reviews, cleaner history, and more incremental development.

The irony is not lost on me that I'm claiming to be a proponent of smaller, more focused PR's while myself trying to contribute a massive PR. However I actually built this feature using stacked PR's (see the screenshots), just figured it'd be easier to have this reviewed in one shot vs... 10

What this PR adds

A new Stacks pane (tab alongside Branches, Remotes, Tags) that provides native git-spice integration:

Visual tree display:

         ┌──◯ feat/auth-ui
         │   #103 open
       ┌─┴● feat/auth-api (✓ ↑2)
       │   #102 open ⟳ restack
     ┌─┴◯ feat/auth-base
     │   #101 merged
 main

Full commit operations - squash, fixup, reword, drop, edit, amend, reset (same keybindings as the Commits pane)

Branch operations - checkout, create, delete, move up/down in stack

Stack operations menu (S key):

  • Restack branch/all
  • Submit branch/all (with options for draft, update-only)
  • Sync repo (pull + delete merged branches)

Navigation menu (G key): - Navigate up/down/top/bottom of stack

Two display formats - Short (hierarchy only) or Long (with PR status, sync indicators, individual commits)

Diff integration - Select any branch or commit to see its diff in the main panel

Following lazygit conventions

I've tried hard to make this feel native to lazygit:

  • Same keybindings - s for squash, f for fixup, Space for checkout, etc.
  • Same patterns - Uses ListControllerTrait, withItem(), require() disabled reasons
  • Same menus - Operations menu (S), copy menu (y), reset menu (g)
  • Same diff behavior - Selecting items shows diff in main panel
  • Same navigation - Enter to drill into commits/files, Escape to go back
  • i18n support - All 58 user-facing strings are translatable
  • Integration tests - 7 test files covering core functionality

Minimal blast radius

This feature is designed to be completely optional:

  1. Disabled by default if gs not installed - The Stacks tab doesn't even appear unless gs is in PATH
  2. Config flag to disable - Users can set git.spice.enabled: false
  3. No changes to existing behavior - All modifications to existing files are additive
image image image image

Testing

  • Unit tests for tree rendering
  • 7 integration tests covering:
    • Basic display
    • Branch checkout/create/delete
    • Log format toggle
    • Navigation menu
    • Stack operations menu

Configuration

 git:
   spice:
     enabled: true      # Enable git-spice integration (default: true)
     logFormat: "short" # Display format: "short" or "long"

I'm happy to address any feedback, split this into smaller PRs if helpful, or discuss alternative approaches. Thanks for considering this! I really hope I don't have to maintain a fork of this project just to keep this feature 😅.

Please check if the PR fulfills these requirements

  • Cheatsheets are up-to-date (run go generate ./...)
  • Code has been formatted (see here)
  • Tests have been added/updated (see here for the integration test guide)
  • Text is internationalised (see here)
  • If a new UserConfig entry was added, make sure it can be hot-reloaded (see here)
  • Docs have been updated if necessary
  • You've read through your own file changes for silly mistakes etc

claude and others added 30 commits January 18, 2026 15:47
Implements a new "Stacks" tab in the branches window that displays git-spice
managed branch stacks when the `gs` CLI is available and the repo is initialized
with git-spice.

Features:
- Displays branch stacks in a tree structure with proper indentation
- Shows branch status (current, needs restack, needs push)
- Displays PR information (number, status)
- Keybindings for stack operations (restack, submit, navigate)
- Integrates with lazygit's refresh and context system
- Only appears when git-spice is available

New files:
- pkg/commands/models/spice.go: Data models for spice stacks
- pkg/commands/git_commands/spice.go: Git-spice CLI commands
- pkg/commands/git_commands/spice_loader.go: Data loader and tree builder
- pkg/gui/presentation/spice_stacks.go: Display string formatting
- pkg/gui/context/spice_stacks_context.go: Context definition
- pkg/gui/controllers/spice_stacks_controller.go: Controller with keybindings

Modified files:
- pkg/commands/git.go: Added Spice commands and loader
- pkg/gui/context/context.go: Added SPICE_STACKS_CONTEXT_KEY
- pkg/gui/context/setup.go: Added SpiceStacksContext initialization
- pkg/gui/controllers.go: Registered SpiceStacksController
- pkg/gui/controllers/helpers/refresh_helper.go: Added refresh logic
- pkg/gui/gui.go: Added "Stacks" tab to branches window
- pkg/gui/types/common.go: Added SpiceStackItems to Model
- pkg/gui/types/refresh.go: Added SPICE_STACKS refresh type
- pkg/gui/types/views.go: Added SpiceStacks view
- pkg/gui/views.go: Added SpiceStacks view configuration and titles
Prevent git-spice commands from running on startup by implementing lazy
loading and caching:

- Cache IsInitialized() result to avoid repeated command execution
- Only check IsAvailable() (PATH lookup) on startup, not IsInitialized()
- Add GetOnFocus() handler with hasRefreshed flag to load data only when
  Stacks tab is first focused
- Fix incorrect initialization command (gs repo status -> gs log short)
- Fix Refresh() return value handling in controller methods
- Remove unused imports (oscommands, fmt)
- Add debug logging to spice_loader for troubleshooting

This ensures git-spice has zero performance impact unless the user actually
switches to the Stacks tab.

Co-Authored-By: Claude <noreply@anthropic.com>
Align keybindings with the local branches pane for consistency:
- Use Universal.Select (space), Universal.New (n), Universal.Remove (d)
- Use Branches.RebaseBranch (r) and Branches.CreatePullRequest (o)
- Add branch creation, deletion, and stack reordering commands
- Use ctrl modifiers for stack navigation to avoid conflicts

New commands:
- gs branch create (n)
- gs branch delete (d)
- gs branch up/down (ctrl-j/k) for reordering within stack

Co-Authored-By: Claude <noreply@anthropic.com>
After creating a branch, deleting, or performing other operations, the
Stacks view was not updating because:

1. Refresh calls didn't include SPICE_STACKS in the scope
2. The hasRefreshed flag wasn't being reset

Additionally, checkout was switching to the branches pane due to using
CheckoutRef helper instead of Git().Branch.Checkout directly.

Changes:
- All operations now reset hasRefreshed flag and explicitly refresh
  SPICE_STACKS scope
- Checkout uses Git().Branch.Checkout to stay in Stacks pane
- Added git_commands import for CheckoutOptions type

Co-Authored-By: Claude <noreply@anthropic.com>
Implement a keybinding (l) and config option to switch between 'gs log short'
and 'gs log long' formats for displaying git-spice stacks. The default is short
format, which shows basic branch hierarchy.

Long format includes:
- PR information (number, URL, status)
- Sync status (ahead/behind, needs-push)
- Commits array with SHA and subject for each commit in the branch

Features:
- Press 'l' in the Stacks tab to toggle between short and long format
- Format choice is persisted in config at git.spice.logFormat
- Commits are displayed as nested, non-interactive items under each branch
  in long format, with proper indentation and styling (cyan SHA + subject)
- Navigation (j/k) skips over commits and only selects branch items
- All branch operations (checkout, restack, submit, delete, move) skip
  commit items to prevent invalid operations

Implementation details:
- Added SpiceConfig struct with LogFormat field to user_config.go
- Modified GetStackBranches() to accept format parameter
- Extended SpiceStackItem and SpiceBranchJSON models with commit fields
- Updated spice_loader to include commits in long format
- Added buildCommitPrefix() to presentation layer for commit display
- Overrode HandleNextLine/HandlePrevLine in controller to skip commits
- Added ensureValidSelection() to guarantee starting on a branch

Co-Authored-By: Claude <noreply@anthropic.com>
Add SPICE_STACKS to refresh scopes in multiple locations:
- background.go: Include in background fetch refresh
- files_controller.go: Include when fetching/pulling
- refs_helper.go: Include when checking out branches

Also removed the hasRefreshed flag that was preventing re-fetches.
Now the spice stacks view will automatically update when:
- New commits are created
- Branches are checked out
- Background fetches occur
- Git push/pull operations complete

This fixes the issue where the spice stacks view showed stale commit
data even after making new commits.

Co-Authored-By: Claude <noreply@anthropic.com>
The SpiceStacksController defines custom HandlePrevLine/HandleNextLine
methods to skip commit items in long mode. However, the automatic
ListController attachment was overriding these with default navigation,
preventing upward (k key) navigation from working properly.

Fixed by excluding SpiceStacks from automatic ListController attachment
and adding explicit navigation keybindings to SpiceStacksController.

Co-Authored-By: Claude <noreply@anthropic.com>
In long mode, the stack view displays commits under each branch, but
only branches are selectable. The footer "X of Y" was counting all
items including commits, which was confusing.

Added custom footer logic to count only branches and calculate which
branch number is currently selected. Also overrode HandleFocus,
HandleRender, and FocusLine to ensure the custom footer is always
displayed correctly.

Co-Authored-By: Claude <noreply@anthropic.com>
Changed the tree traversal to start from leaves (top of stack) and traverse
down to the base, instead of starting from roots (base) and going up.

This matches the gs log output where the top of the stack appears first
and the base branch appears at the bottom:

Before (incorrect):
  master
  └─ claude/add-spice-stacks-tab-x1jBG
     └─ spice-stacks-long-desc

After (correct):
  spice-stacks-long-desc
  └─ claude/add-spice-stacks-tab-x1jBG
     └─ master

Changes:
- Find leaves (branches with no Ups) instead of roots
- DFS traverses down (to parent) instead of up (to children)
- Depth increases as we go down the stack
- Removed unused children map

Co-Authored-By: Claude <noreply@anthropic.com>
The hasRefreshed field was accidentally removed during rebase but is still
referenced throughout the controller. Re-add it to fix build errors.

Co-Authored-By: Claude <noreply@anthropic.com>
Replace ├ (Box Drawings Light Vertical and Right) with ┌ (Light Down and Right) at the branch level in git-spice stack rendering for cleaner visual appearance.

Co-Authored-By: Claude <noreply@anthropic.com>
Remove bullet points from commit display so commit SHAs align directly with branch names for cleaner visual hierarchy.

Co-Authored-By: Claude <noreply@anthropic.com>
Changed the tree traversal to start from leaves (top of stack) and traverse
down to the base, instead of starting from roots (base) and going up.

This matches the gs log output where the top of the stack appears first
and the base branch appears at the bottom:

Before (incorrect):
  master
  └─ claude/add-spice-stacks-tab-x1jBG
     └─ spice-stacks-long-desc

After (correct):
  spice-stacks-long-desc
  └─ claude/add-spice-stacks-tab-x1jBG
     └─ master

Changes:
- Find leaves (branches with no Ups) instead of roots
- DFS traverses down (to parent) instead of up (to children)
- Depth increases as we go down the stack
- Removed unused children map

Co-Authored-By: Claude <noreply@anthropic.com>
- Copy git-spice's fliptree rendering approach (children before parents)
- Add SiblingIndex field to track sibling position for proper connectors
- Use lighter weight box-drawing characters (│ ┌ ├ ┴ ─)
- Add branch indicators: ◯ for regular branches, ● for current branch
- Fix commit ordering in long format (commits after their branch)
- Ensure branch names align with commit hashes in long format
- Add comprehensive unit tests for tree prefix rendering

The rendering now matches gs log output with correct:
- Vertical pipe placement based on ancestor sibling indices
- Branch connectors (┌─ for first sibling, ├─ for others)
- Horizontal-up connector (┴) when branch has children
- Consistent spacing between indicators and branch names

Co-Authored-By: Claude <noreply@anthropic.com>
… mode

In long stack log mode, sibling branches were incorrectly showing the ┴
connector (indicating they have children) when they didn't. This happened
because hasItemsAtDepthBefore was counting commits from sibling branches
as children. Fix by filtering out commits and only counting actual branches.

Co-Authored-By: Claude <noreply@anthropic.com>
When a branch has children, its prefix includes the ┴ character making
it one character wider. The commit prefix now accounts for this by
adding an extra space when the parent branch has children.

Co-Authored-By: Claude <noreply@anthropic.com>
The hasItemsAtDepthBefore() function was checking for any branch at
depth+1 before the current index, but this incorrectly included
children of sibling branches. Now it iterates backwards and stops
when encountering a sibling, ensuring only actual children are counted.

Co-Authored-By: Claude <noreply@anthropic.com>
Combine branch name and status decorations into a single column so
decorations (✓, ⟳ restack, ↑ push, ↓N) appear immediately after each
branch name instead of being aligned in a separate padded column.

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove unused hasRefreshed field and all assignments (dead code)
- Fix incorrect status messages: use SpiceRestackingStatus and
  SpiceSubmittingStatus instead of DeletingStatus
- Add i18n translations for all hard-coded UI strings
- Add Enabled config option (git.spice.enabled) to disable integration
- Create spice_integration.go with isSpiceEnabled() helper

Co-Authored-By: Claude <noreply@anthropic.com>
The Stacks pane wasn't refreshing when operations like commits,
branch creation, or rebases occurred because SPICE_STACKS was
missing from the default refresh scope.

Co-Authored-By: Claude <noreply@anthropic.com>
Removes custom navigation that skipped commits, allowing users to select both branches and commits. Adds gs commit fixup integration for amending staged changes into selected commits. Uses disabled reasons to properly restrict branch-only commands.

Co-Authored-By: Claude <noreply@anthropic.com>
The commit command handlers had several issues:

1. Hash lookup used exact equality (==) comparing 7-char short hashes
   against 40-char full hashes, so commits were never found. Fixed by
   using strings.HasPrefix for prefix matching.

2. Rebase operations didn't wrap errors with CheckMergeOrRebase, so
   conflicts weren't handled properly. Added the wrapper to all rebase
   operations (squash, fixup, drop, edit, reword, amend).

3. commitCopy passed LocalCommits context to CopyRange, but the user is
   in SpiceStacks context, so the selection range was wrong. Fixed by
   directly manipulating cherry-pick data for the specific commit.

Also replaced the spice-specific commitFixup with standard commit
commands that mirror the local commits view behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
Enter on a branch row opens SubCommits view for that branch.
Enter on a commit row opens CommitFiles view showing the diff.
Escape returns to spice stacks via parent context tracking.

Co-Authored-By: Claude <noreply@anthropic.com>
Consolidate duplicate Universal.Select bindings into a single binding
with a unified press handler that routes to commitCheckout or checkout
based on item type. Previously, having two bindings for the same key
caused the first one's disabled reason to show when on a branch.

Co-Authored-By: Claude <noreply@anthropic.com>
When navigating the stacks view, the main window now updates based on
the selected row - showing commit log for branches and diff/patch for
commits, matching the behavior of branches and commits views.

Co-Authored-By: Claude <noreply@anthropic.com>
Reorganize keybindings in the Stacks pane to reduce conflicts with
standard lazygit commands by consolidating operations into two menus:
- S: Stack Operations (restack, submit, create, delete, move branch)
- G: Stack Navigation (up, down, top, bottom)

This frees up keys like n, r, o, and ctrl+u/d/j/k for their standard
lazygit functions. Also changes log toggle from 'l' to '~' to avoid
conflict with panel navigation.

Co-Authored-By: Claude <noreply@anthropic.com>
- ctrl-j/k now works on both branches and commits:
  - Branch selected: moves branch up/down in stack order (gs branch up/down)
  - Commit selected: moves commit up/down via interactive rebase
- Changed log format toggle from ~ to V (Shift+V)
- Removed "Move branch up/down" from Stack Operations menu since
  ctrl-j/k is now a direct keybinding

Co-Authored-By: Claude <noreply@anthropic.com>
Use DescriptionFunc to show "Move branch down/up in stack" when a branch
is selected, or "Move commit down/up" when a commit is selected.

Co-Authored-By: Claude <noreply@anthropic.com>
Add SubmitOpts to support --no-publish and --update-only flags for
git-spice submit commands. The Stack Operations menu now has:
- Direct submit items (s/S) for default behavior
- Options submenus (o/O) with no-publish, update-only, and default

Co-Authored-By: Claude <noreply@anthropic.com>
josephschmitt and others added 17 commits January 20, 2026 21:10
When a commit is selected, pressing 'y' opens a menu to copy commit
attributes: hash, subject, message, URL, diff, or author. The binding
is disabled when a branch is selected.

Co-Authored-By: Claude <noreply@anthropic.com>
- n: Create new branch with selected branch as target (or parent branch if commit selected)
- N: Create new commit using gs commit create on current branch

Co-Authored-By: Claude <noreply@anthropic.com>
When git-spice is available but not initialized, the stacks pane now
shows an instructional message prompting the user to press ENTER.
Pressing ENTER opens a branch picker to select the trunk branch,
then runs `gs repo init --trunk <branch>` to initialize git-spice.

Co-Authored-By: Claude <noreply@anthropic.com>
Replace toggle with a menu (V key) offering Short/Long/Default options.
The selection persists in state.yml under spice.logFormat, with precedence:
AppState override > UserConfig default > "short".

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove getEffectiveLogFormat() - duplicate of function in spice_loader.go
- Remove branchSelected() - never called, branchSelectedReason() is used instead

Co-Authored-By: Claude <noreply@anthropic.com>
These functions were implemented but never used in the codebase.

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove SpiceCommitFixup and SpiceFixupStatus from TranslationSet
- These were unused after removing CommitFixup function

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove IsLast field from SpiceStackItem model
- Remove PRURL field from SpiceStackItem model
- Remove isLast parameter from dfs function in spice_loader.go
- These fields were set but never read

Co-Authored-By: Claude <noreply@anthropic.com>
The continuing map was passed to buildTreePrefix and buildCommitPrefix
but never actually used within those functions.

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove section divider comments from spice_stacks_controller.go
- Remove redundant doc comments from spice.go
- Remove redundant comments from spice_stacks_context.go

Co-Authored-By: Claude <noreply@anthropic.com>
Migrate all hardcoded strings in spice_stacks_controller.go to use
translation keys from the TranslationSet. This includes:
- Menu titles and labels
- Navigation tooltips
- Error messages
- Status descriptions

Co-Authored-By: Claude <noreply@anthropic.com>
- Add findCommitOrError() helper that combines findCommitByHash with error handling
- Add refreshSpiceStacksOnly() helper for spice-only view refresh
- Add refreshAllViews() helper for full view refresh
- Update all callers to use the new helper methods

Co-Authored-By: Claude <noreply@anthropic.com>
Change the status display from showing "↑ push" to showing the actual
commit count like "↑3" when there are unpushed commits. This provides
more useful information about how many commits are ahead.

- Update formatStatus() to use Ahead count instead of NeedsPush boolean
- Remove unused NeedsPush field from SpiceStackItem model

Co-Authored-By: Claude <noreply@anthropic.com>
- Add SpiceStacks() view helper to integration test framework
- Add spiceStacks to cheatsheet context title map
- Create 6 integration test files for spice stacks feature:
  - basic_display.go: View shows stacks when initialized
  - checkout_branch.go: Checkout branch from stacks view
  - create_branch.go: Create new branch (n key)
  - delete_branch.go: Delete branch from stack
  - navigation_menu.go: Stack navigation menu (G key)
  - stack_operations_menu.go: Stack operations menu (S key)
  - log_format_toggle.go: Toggle between short/long format (V key)

Note: Tests are skipped by default as they require git-spice to be installed

Also fix compilation error: use = instead of := for err reassignment

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove continuing parameter from test file to match function signature
- Fix gofumpt formatting issues
- Use _ for unused buildCommitPrefix item parameter

Co-Authored-By: Claude <noreply@anthropic.com>
Nodes without children were indented 1 character less than nodes with
children, causing siblings to not line up properly. This adds a
horizontal line (─) when nodes don't have children, making all joints
consistently 3 characters wide so siblings align correctly.

Co-Authored-By: Claude <noreply@anthropic.com>
Add `gs repo sync` command to the Stack Operations menu (S key) with
two options: sync (y) and sync with restack (Y). This pulls latest
changes from remote and deletes branches with merged PRs.

Co-Authored-By: Claude <noreply@anthropic.com>
@josephschmitt josephschmitt marked this pull request as ready for review January 21, 2026 03:33
josephschmitt and others added 2 commits January 21, 2026 15:11
The Sync function was incorrectly using NewGitCmd("gs") which prepends "git" to the command, resulting in "git gs repo sync" instead of "gs repo sync". This caused the error: "git: 'gs' is not a git command".

Changed to use a plain slice []string{"gs", "repo", "sync"} to match the pattern used by all other spice commands in the file.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The gs log command was missing the -S flag which requests Change Request status information. Without it, the status field in the JSON output was empty, causing PR numbers to display in white instead of their status-based colors (green for open, magenta for merged, red for closed).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants