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/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} 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"]}}