Skip to content
Open
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
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260601-113020.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: '''log'': Display the configured integration branch as a top row in ''gs ls''/''gs ll'', and mark each configured tip with ''[integration-tip]''.'
time: 2026-06-01T11:30:20.430333-04:00
36 changes: 21 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,11 @@ Before merging, the stack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.

Before each merge, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait
before failing if checks are not ready.
Before each merge, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
before failing if merge readiness is not reached.

By default, a branch failure skips that branch's upstack descendants,
but independent sibling branches continue.
Expand All @@ -321,12 +323,12 @@ Use --fail-fast to stop the queue after the first branch failure.
**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--no-branch-check`: Skip stale base validation before merging.
* `--fail-fast`: Stop the merge queue after the first branch failure.
* `--branch=NAME`: Branch whose stack to merge

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice stack restack {#gs-stack-restack}

Expand Down Expand Up @@ -624,7 +626,7 @@ This command acts as a local merge queue:
it merges one Change Request,
waits for that merge to finish,
restacks and updates the next Change Request,
waits for its CI checks to pass,
waits for merge readiness on the updated Change Request,
and then repeats the process.

For a stack like this:
Expand All @@ -642,13 +644,15 @@ Before merging, the downstack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.

Before each merge, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait
Before each merge, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
(default: 30m, 0 means fail immediately if not ready).

Between merges, the command waits for each merge
to complete, restacks and updates the next PR,
waits for CI checks on the updated PR,
waits for merge readiness on the updated PR,
and syncs merged branch cleanup.

Use --no-wait for single branch merging
Expand All @@ -658,12 +662,12 @@ when you don't want to wait for the merge to propagate.
**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--no-wait`: Skip polling for a single branch merge to propagate.
* `--no-branch-check`: Skip stale base validation before merging.
* `--branch=NAME`: Branch to start merging from

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice downstack edit {#gs-downstack-edit}

Expand Down Expand Up @@ -1133,16 +1137,18 @@ Use --branch to merge a different branch.
The branch must be based directly on trunk.
To merge a stacked branch, use 'gs downstack merge'.

Before merging, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait.
Before merging, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait.

**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--branch=NAME`: Branch to merge

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice branch submit {#gs-branch-submit}

Expand Down
72 changes: 67 additions & 5 deletions internal/handler/list/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var _ GitRepository = (*git.Repository)(nil)
type Store interface {
Remote() (state.Remote, error)
Trunk() string
Integration(ctx context.Context) (*state.IntegrationInfo, error)
}

var _ Store = (*state.Store)(nil)
Expand Down Expand Up @@ -120,6 +121,10 @@ type BranchesRequest struct {
type BranchesResponse struct {
Branches []*BranchItem
TrunkIdx int

// IntegrationIdx is the index of the synthetic integration branch
// item in Branches, or -1 if no integration branch is configured.
IntegrationIdx int
}

// BranchItem is a single branch in the log output.
Expand Down Expand Up @@ -150,6 +155,21 @@ type BranchItem struct {
// NeedsRestack indicates whether this branch needs to be restacked
// on top of its base branch.
NeedsRestack bool

// IntegrationTip indicates that this branch is a configured tip of
// the integration branch.
IntegrationTip bool

// Integration is non-nil for the synthetic integration branch row.
// Regular branch rows always have nil Integration.
Integration *IntegrationDisplay
}

// IntegrationDisplay carries presentation-relevant information about
// the configured integration branch.
type IntegrationDisplay struct {
// Tips lists the configured tip branch names in declaration order.
Tips []string
}

// PushStatus contains push-related information
Expand Down Expand Up @@ -344,16 +364,57 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran
items = append(items, trunkItem)
itemByName[trunkItem.Name] = trunkItem

// Load the integration branch configuration so the row can be
// surfaced alongside regular branches, and tip branches can be
// marked. A missing integration is not an error: most repos don't
// have one.
integration, err := h.Store.Integration(ctx)
if errors.Is(err, state.ErrNotExist) {
integration = nil
} else if err != nil {
log.Warn("Could not load integration branch configuration", "error", err)
integration = nil
}

// Mark tip rows and synthesize an item for the integration branch
// itself. The integration branch is repo-scoped (singleton) and
// not tracked, so it has no base and no aboves.
if integration != nil {
tipNames := make([]string, 0, len(integration.Tips))
for _, tip := range integration.Tips {
tipNames = append(tipNames, tip.Name)
if item, ok := itemByName[tip.Name]; ok {
item.IntegrationTip = true
}
}
intItem := &BranchItem{
Name: integration.Name,
Integration: &IntegrationDisplay{
Tips: tipNames,
},
}
items = append(items, intItem)
itemByName[intItem.Name] = intItem
}

slices.SortFunc(items, func(a, b *BranchItem) int {
return strings.Compare(a.Name, b.Name)
})

// Connect the Above relationships.
var trunkIdx int
// Connect the Above relationships. Skip the integration row: it is
// untracked and never appears as another branch's base.
var (
trunkIdx int
integrationIdx = -1
)
for idx, item := range items {
if item.Name == branchGraph.Trunk() {
switch {
case item.Name == branchGraph.Trunk():
trunkIdx = idx
continue
case item.Integration != nil:
integrationIdx = idx
continue
}

baseItem, ok := itemByName[item.Base]
Expand All @@ -374,8 +435,9 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran
}

return &BranchesResponse{
TrunkIdx: trunkIdx,
Branches: items,
TrunkIdx: trunkIdx,
IntegrationIdx: integrationIdx,
Branches: items,
}, nil
}

Expand Down
62 changes: 62 additions & 0 deletions internal/ui/branchtree/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,26 @@ type Item struct {
// It is invalid for both Disabled and Highlighted to be true.
Disabled bool

// IntegrationTip indicates this branch is a configured tip of the
// integration branch. Rendered as a trailing "[integration-tip]"
// annotation.
IntegrationTip bool

// Integration is non-nil for the synthetic integration branch row.
// The row gets a distinct node marker and a trailing
// "[integration: N tips]" annotation.
Integration *Integration

// TODO: enum for highlighted/disabled state?
}

// Integration holds presentation-relevant data for the integration
// branch row.
type Integration struct {
// TipCount is the number of configured tips.
TipCount int
}

// PushStatus contains push-related information
// if the branch has been pushed to a remote.
type PushStatus struct {
Expand Down Expand Up @@ -171,6 +188,18 @@ type Style struct {
// Must include the marker character via SetString.
NodeMarkerDisabled ui.Style

// NodeMarkerIntegration styles the node marker for the integration
// branch row. Must include the marker character via SetString.
NodeMarkerIntegration ui.Style

// Integration styles the trailing "[integration: N tips]"
// annotation on the integration branch row.
Integration ui.Style

// IntegrationTip styles the trailing "[integration-tip]"
// annotation on configured tip branches.
IntegrationTip ui.Style

// TextHighlight styles characters matching fuzzy search.
TextHighlight ui.Style

Expand Down Expand Up @@ -214,6 +243,9 @@ var DefaultStyle = Style{
NodeMarker: fliptree.DefaultNodeMarker,
NodeMarkerHighlighted: fliptree.DefaultNodeMarker.SetString("■"),
NodeMarkerDisabled: fliptree.DefaultNodeMarker.Faint(true),
NodeMarkerIntegration: fliptree.DefaultNodeMarker.SetString("◆").Foreground(ui.Magenta),
Integration: ui.NewStyle().Foreground(ui.Magenta),
IntegrationTip: ui.NewStyle().Foreground(ui.Magenta).Faint(true),
TextHighlight: ui.NewStyle().Foreground(ui.Cyan),
Marker: ui.NewStyle().Foreground(ui.Yellow).Bold(true).SetString("◀"),
}
Expand Down Expand Up @@ -289,6 +321,8 @@ func Write(w io.Writer, g Graph, opts *GraphOptions) error {
treeStyle := fliptree.DefaultStyle[*Item]()
treeStyle.NodeMarker = func(item *Item) ui.Style {
switch {
case item.Integration != nil:
return opts.Style.NodeMarkerIntegration
case item.Disabled:
return opts.Style.NodeMarkerDisabled
case item.Highlighted:
Expand Down Expand Up @@ -327,6 +361,14 @@ func (r *branchTreeRenderer) RenderItem(item *Item) string {
}

func (r *branchTreeRenderer) item(sb *strings.Builder, item *Item) {
// The integration row renders as its own root in the fliptree,
// which means the tree skips the node marker for it. Re-render
// the marker inline so the row still has a visual anchor.
if item.Integration != nil {
sb.WriteString(r.Style.NodeMarkerIntegration.String())
sb.WriteString(" ")
}

r.branchName(sb, item)

if item.ChangeID != "" {
Expand All @@ -346,6 +388,20 @@ func (r *branchTreeRenderer) item(sb *strings.Builder, item *Item) {
sb.WriteString(r.Style.NeedsRestack.String())
}

if item.Integration != nil {
noun := "tips"
if item.Integration.TipCount == 1 {
noun = "tip"
}
sb.WriteString(r.Style.Integration.Render(
fmt.Sprintf(" [integration: %d %s]", item.Integration.TipCount, noun),
))
}

if item.IntegrationTip {
sb.WriteString(r.Style.IntegrationTip.Render(" [integration-tip]"))
}

r.pushStatus(sb, item.PushStatus)

if item.Highlighted {
Expand Down Expand Up @@ -533,6 +589,9 @@ type branchTreeStyle struct {
NodeMarker lipgloss.Style
NodeMarkerHighlighted lipgloss.Style
NodeMarkerDisabled lipgloss.Style
NodeMarkerIntegration lipgloss.Style
Integration lipgloss.Style
IntegrationTip lipgloss.Style
TextHighlight lipgloss.Style
Marker lipgloss.Style
}
Expand All @@ -552,6 +611,9 @@ func (s Style) resolve(theme ui.Theme) branchTreeStyle {
NodeMarker: s.NodeMarker.Resolve(theme),
NodeMarkerHighlighted: s.NodeMarkerHighlighted.Resolve(theme),
NodeMarkerDisabled: s.NodeMarkerDisabled.Resolve(theme),
NodeMarkerIntegration: s.NodeMarkerIntegration.Resolve(theme),
Integration: s.Integration.Resolve(theme),
IntegrationTip: s.IntegrationTip.Resolve(theme),
TextHighlight: s.TextHighlight.Resolve(theme),
Marker: s.Marker.Resolve(theme),
}
Expand Down
Loading
Loading