From 157630ccca1eb896710ccdfb5e15bce93343e426 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Mon, 1 Jun 2026 11:30:36 -0400 Subject: [PATCH 1/2] integration: surface integration branch and tips in gs ll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration branch was previously invisible to the log commands: users could only learn about it via 'gs integration show'. That made it easy to forget which tips were participating, or to overlook the integration entirely when scanning the stack with 'gs ll'. Show the integration as a standalone top row of 'gs ls' and 'gs ll' (rendered with a magenta '◆' marker and a "[integration: N tips]" annotation), and mark each configured tip in the normal tree with a trailing "[integration-tip]" badge so the relationship reads at a glance. The integration item is wired into the list handler as a synthetic BranchItem with no base and no aboves, then exposed as a separate root in the branchtree graph so it renders before the trunk tree. The JSON output gains an "integration" record on the integration row and an "integrationTip" boolean on tip rows for programmatic consumers. No tree edges are drawn between the integration row and its tips: that fan-in does not fit cleanly into the existing top-down layout and the markers already convey the relationship clearly enough. --- .../unreleased/Added-20260601-113020.yaml | 3 + internal/handler/list/handler.go | 72 +++++++++++++++++-- internal/ui/branchtree/tree.go | 62 ++++++++++++++++ log.go | 44 ++++++++++-- .../integration_branch_cmds_excluded.txt | 9 ++- testdata/script/integration_create.txt | 8 ++- testdata/script/log_integration.txt | 67 +++++++++++++++++ 7 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 .changes/unreleased/Added-20260601-113020.yaml create mode 100644 testdata/script/log_integration.txt diff --git a/.changes/unreleased/Added-20260601-113020.yaml b/.changes/unreleased/Added-20260601-113020.yaml new file mode 100644 index 000000000..5bce681cb --- /dev/null +++ b/.changes/unreleased/Added-20260601-113020.yaml @@ -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 diff --git a/internal/handler/list/handler.go b/internal/handler/list/handler.go index f71702562..d6a80e763 100644 --- a/internal/handler/list/handler.go +++ b/internal/handler/list/handler.go @@ -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) @@ -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. @@ -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 @@ -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] @@ -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 } diff --git a/internal/ui/branchtree/tree.go b/internal/ui/branchtree/tree.go index 14334d0f5..4b1fd8d66 100644 --- a/internal/ui/branchtree/tree.go +++ b/internal/ui/branchtree/tree.go @@ -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 { @@ -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 @@ -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("◀"), } @@ -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: @@ -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 != "" { @@ -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 { @@ -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 } @@ -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), } diff --git a/log.go b/log.go index 58c03dac3..436d84977 100644 --- a/log.go +++ b/log.go @@ -187,11 +187,17 @@ func (p *graphLogPresenter) Present(res *list.BranchesResponse, currentBranch st items := make([]*branchtree.Item, len(res.Branches)) for i, b := range res.Branches { item := &branchtree.Item{ - Branch: b.Name, - Worktree: b.Worktree, - NeedsRestack: b.NeedsRestack, - Aboves: b.Aboves, - Highlighted: b.Name == currentBranch, + Branch: b.Name, + Worktree: b.Worktree, + NeedsRestack: b.NeedsRestack, + Aboves: b.Aboves, + Highlighted: b.Name == currentBranch, + IntegrationTip: b.IntegrationTip, + } + if b.Integration != nil { + item.Integration = &branchtree.Integration{ + TipCount: len(b.Integration.Tips), + } } // Format change ID based on requested format. @@ -248,9 +254,16 @@ func (p *graphLogPresenter) Present(res *list.BranchesResponse, currentBranch st pushFmt = branchtree.PushStatusDisabled } + // Render integration first when configured, so it appears as the + // top row of the output. Each root is its own subtree, so the + // integration row stands alone above the trunk tree. + roots := []int{res.TrunkIdx} + if res.IntegrationIdx >= 0 { + roots = []int{res.IntegrationIdx, res.TrunkIdx} + } g := branchtree.Graph{ Items: items, - Roots: []int{res.TrunkIdx}, + Roots: roots, } opts := &branchtree.GraphOptions{ @@ -285,6 +298,13 @@ func (p *jsonLogPresenter) Present(res *list.BranchesResponse, currentBranch str Current: branch.Name == currentBranch, } + if branch.Integration != nil { + logBranch.Integration = &jsonLogIntegration{ + Tips: branch.Integration.Tips, + } + } + logBranch.IntegrationTip = branch.IntegrationTip + if branch.Base != "" { logBranch.Down = &jsonLogDown{ Name: branch.Base, @@ -366,6 +386,13 @@ type jsonLogBranch struct { // This is false or omitted if this is not the current branch. Current bool `json:"current,omitempty"` + // Integration is set for the synthetic integration branch row. + Integration *jsonLogIntegration `json:"integration,omitempty"` + + // IntegrationTip is true if this branch is a configured tip of + // the integration branch. + IntegrationTip bool `json:"integrationTip,omitempty"` + // Down is the base branch onto which this branch is stacked. // This is unset if this branch is trunk. // 'git-spice down' from the current branch will check out this branch. @@ -399,6 +426,11 @@ type jsonLogBranch struct { Worktree string `json:"worktree,omitempty"` } +type jsonLogIntegration struct { + // Tips lists the configured tip branch names in declaration order. + Tips []string `json:"tips"` +} + type jsonLogDown struct { // Name of the base branch. Name string `json:"name"` diff --git a/testdata/script/integration_branch_cmds_excluded.txt b/testdata/script/integration_branch_cmds_excluded.txt index 0fdcd5669..6d170a18a 100644 --- a/testdata/script/integration_branch_cmds_excluded.txt +++ b/testdata/script/integration_branch_cmds_excluded.txt @@ -1,5 +1,8 @@ # Verifies that the integration branch is invisible to 'gs branch' # commands: it is not tracked, and an attempt to track it is rejected. +# (The branch still surfaces in 'gs ls'/'gs ll' as the integration +# row, but that is purely informational and does not make the branch +# trackable.) as 'Test ' at '2025-01-01T00:00:00Z' @@ -16,7 +19,8 @@ git checkout main gs integration create preview --tip feat-a gs integration rebuild -# 'gs ll' does not include preview. +# 'gs ls' shows the integration branch as a top row with the tip +# marked, but the integration row is not a regular tracked branch. gs ls cmp stderr $WORK/golden/ls.txt @@ -28,5 +32,6 @@ stderr 'cannot track integration branch' -- repo/feat-a.txt -- feature a -- golden/ls.txt -- -┏━□ feat-a +◆ preview [integration: 1 tip] +┏━□ feat-a [integration-tip] main ◀ diff --git a/testdata/script/integration_create.txt b/testdata/script/integration_create.txt index cc4c31e8a..c1352587e 100644 --- a/testdata/script/integration_create.txt +++ b/testdata/script/integration_create.txt @@ -25,7 +25,8 @@ gs integration create preview --tip feat-a --tip feat-b gs integration show cmp stdout $WORK/golden/show.txt -# The integration branch is not tracked by 'gs branch checkout' / 'gs ll'. +# The integration is surfaced as a top row in 'gs ll' even before +# its branch has been materialized, with each configured tip marked. gs ls cmp stderr $WORK/golden/ls.txt @@ -44,6 +45,7 @@ Tips: - feat-a (pending rebuild) - feat-b (pending rebuild) -- golden/ls.txt -- -┏━□ feat-a -┣━□ feat-b +◆ preview [integration: 2 tips] +┏━□ feat-a [integration-tip] +┣━□ feat-b [integration-tip] main ◀ diff --git a/testdata/script/log_integration.txt b/testdata/script/log_integration.txt new file mode 100644 index 000000000..27bbf0e3a --- /dev/null +++ b/testdata/script/log_integration.txt @@ -0,0 +1,67 @@ +# Verifies that 'gs ls' and 'gs ll' display the configured integration +# branch as a top row, with each tip marked '[integration-tip]'. + +as 'Test ' +at '2025-06-01T00:00:00Z' + +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Build two stacks rooted at trunk: +# main -> feat-a -> feat-a-up +# main -> feat-b +git add feat-a.txt +gs bc feat-a -m 'Add feat-a' +git add feat-a-up.txt +gs bc feat-a-up -m 'Add feat-a-up' + +gs trunk +git add feat-b.txt +gs bc feat-b -m 'Add feat-b' + +gs trunk +gs integration create preview --tip feat-a --tip feat-b +gs integration rebuild + +# 'gs ls -a' shows the integration branch as a top row and marks the +# tips with [integration-tip]. +gs ls -a +cmp stderr $WORK/golden/ls-all.txt + +# 'gs ll -a' shows the same with commits below each branch. +gs ll -a +cmp stderr $WORK/golden/ll-all.txt + +# 'gs ls --json' emits an "integration" record alongside branches. +gs ls --json -a +cmp stdout $WORK/golden/ls-all.json + +-- repo/feat-a.txt -- +feature a +-- repo/feat-a-up.txt -- +feature a up +-- repo/feat-b.txt -- +feature b +-- golden/ls-all.txt -- +◆ preview [integration: 2 tips] + ┏━□ feat-a-up +┏━┻□ feat-a [integration-tip] +┣━□ feat-b [integration-tip] +main ◀ +-- golden/ll-all.txt -- +◆ preview [integration: 2 tips] + ┏━□ feat-a-up + ┃ 0be0594 Add feat-a-up (now) +┏━┻□ feat-a [integration-tip] +┃ cc7e211 Add feat-a (now) +┣━□ feat-b [integration-tip] +┃ 3183ddc Add feat-b (now) +main ◀ +-- golden/ls-all.json -- +{"name":"feat-a","integrationTip":true,"down":{"name":"main"},"ups":[{"name":"feat-a-up"}]} +{"name":"feat-a-up","down":{"name":"feat-a"}} +{"name":"feat-b","integrationTip":true,"down":{"name":"main"}} +{"name":"main","current":true,"ups":[{"name":"feat-a"},{"name":"feat-b"}]} +{"name":"preview","integration":{"tips":["feat-a","feat-b"]}} From 5ee2f441d3392b6fd4cf4920d03695b33870abd7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:37:56 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ecad060ec..7ea9b9192 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -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. @@ -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} @@ -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: @@ -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 @@ -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} @@ -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}