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-20260609-045211.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'restack: Add ''--skip-conflicts'' to ''gs repo restack'', ''gs stack restack'', ''gs upstack restack'', ''gs downstack restack'', and ''gs branch restack'' to skip branches that cannot be rebased cleanly and their upstacks instead of stopping for conflict resolution.'
time: 2026-06-09T04:52:11.175578-07:00
5 changes: 5 additions & 0 deletions branch_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (

type branchRestackCmd struct {
Branch string `placeholder:"NAME" help:"Branch to restack" predictor:"trackedBranches"`

SkipConflicts bool `help:"Skip branches that cannot be rebased due to conflicts"`
}

func (*branchRestackCmd) Help() string {
Expand All @@ -35,5 +37,8 @@ func (cmd *branchRestackCmd) AfterApply(ctx context.Context, wt *git.Worktree) e
func (cmd *branchRestackCmd) Run(ctx context.Context, handler RestackHandler) error {
return handler.RestackBranch(ctx, &restack.BranchRequest{
Branch: cmd.Branch,
Options: &restack.Options{
SkipConflicts: cmd.SkipConflicts,
},
})
}
14 changes: 13 additions & 1 deletion doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ of deleted branches, leaving higher branches in place.
### git-spice repo restack {#gs-repo-restack}

```
gs repo (r) restack (r)
gs repo (r) restack (r) [flags]
```

<span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.16.0](/changelog.md#v0.16.0)</span></span>
Expand All @@ -181,6 +181,12 @@ Restack all tracked branches
All tracked branches in the repository are rebased on top of their
respective bases in dependency order, ensuring a linear history.

Use --skip-conflicts to skip branches that cannot be rebased cleanly.

**Flags**

* `--skip-conflicts`: Skip branches that cannot be rebased due to conflicts

## Log

### git-spice log short {#gs-log-short}
Expand Down Expand Up @@ -359,9 +365,12 @@ respective bases, ensuring a linear history.

Use --branch to rebase the stack of a different branch.

Use --skip-conflicts to skip branches that cannot be rebased cleanly.

**Flags**

* `--branch=NAME`: Branch to restack the stack of
* `--skip-conflicts`: Skip branches that cannot be rebased due to conflicts

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

Expand Down Expand Up @@ -488,6 +497,7 @@ but still rebase all branches above it.

**Flags**

* `--skip-conflicts`: Skip branches that cannot be rebased due to conflicts
* `--skip-start`: Do not restack the starting branch
* `--branch=NAME`: Branch to restack the upstack of

Expand Down Expand Up @@ -727,6 +737,7 @@ Use --branch to start at a different branch.
**Flags**

* `--branch=NAME`: Branch to restack the downstack of
* `--skip-conflicts`: Skip branches that cannot be rebased due to conflicts

## Branch

Expand Down Expand Up @@ -1092,6 +1103,7 @@ Use --branch to target a different branch.
**Flags**

* `--branch=NAME`: Branch to restack
* `--skip-conflicts`: Skip branches that cannot be rebased due to conflicts

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

Expand Down
5 changes: 5 additions & 0 deletions downstack_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (

type downstackRestackCmd struct {
Branch string `help:"Branch to restack the downstack of" placeholder:"NAME" predictor:"trackedBranches"`

SkipConflicts bool `help:"Skip branches that cannot be rebased due to conflicts"`
}

func (*downstackRestackCmd) Help() string {
Expand Down Expand Up @@ -47,5 +49,8 @@ func (cmd *downstackRestackCmd) Run(

return handler.RestackDownstack(ctx, &restack.DownstackRequest{
Branch: cmd.Branch,
Options: &restack.Options{
SkipConflicts: cmd.SkipConflicts,
},
})
}
7 changes: 6 additions & 1 deletion internal/handler/restack/branch.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package restack

import "context"
import (
"cmp"
"context"
)

// BranchRequest is a request to restack one branch onto its base.
type BranchRequest struct {
Expand All @@ -12,9 +15,11 @@ type BranchRequest struct {

// RestackBranch restacks the given branch onto its base.
func (h *Handler) RestackBranch(ctx context.Context, req *BranchRequest) error {
opts := cmp.Or(req.Options, &Options{})
_, err := h.Restack(ctx, &Request{
Branch: req.Branch,
ContinueCommand: []string{"branch", "restack"},
SkipConflicts: opts.SkipConflicts,
})
return err
}
44 changes: 44 additions & 0 deletions internal/handler/restack/branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,50 @@ func TestHandler_RestackBranch(t *testing.T) {
}))
})

t.Run("SkipConflicts", func(t *testing.T) {
var logBuffer bytes.Buffer
log := silog.New(&logBuffer, nil)
ctrl := gomock.NewController(t)

mockService := NewMockService(ctrl)
mockService.EXPECT().
BranchGraph(gomock.Any(), gomock.Any()).
Return(newBranchGraphBuilder("main").
Branch("feature", "main").
Build(t), nil)
mockService.EXPECT().
Restack(gomock.Any(), "feature").
Return(nil, &git.RebaseInterruptError{
Kind: git.RebaseInterruptConflict,
})

mockWorktree := NewMockGitWorktree(ctrl)
mockWorktree.EXPECT().
RootDir().
Return(t.TempDir())
mockWorktree.EXPECT().
RebaseAbort(gomock.Any()).
Return(nil)
mockWorktree.EXPECT().
CheckoutBranch(gomock.Any(), "feature").
Return(nil)

handler := &Handler{
Log: log,
Worktree: mockWorktree,
Store: statetest.NewMemoryStore(t, "main", "", log),
Service: mockService,
}

require.NoError(t, handler.RestackBranch(t.Context(), &BranchRequest{
Branch: "feature",
Options: &Options{
SkipConflicts: true,
},
}))
assert.Contains(t, logBuffer.String(), "feature: conflict, skipping")
})

t.Run("UntrackedBranch", func(t *testing.T) {
var logBuffer bytes.Buffer
log := silog.New(&logBuffer, nil)
Expand Down
7 changes: 6 additions & 1 deletion internal/handler/restack/downstack.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package restack

import "context"
import (
"cmp"
"context"
)

// DownstackRequest is a request to restack a branch and its downstack.
type DownstackRequest struct {
Expand All @@ -13,10 +16,12 @@ type DownstackRequest struct {
// RestackDownstack restacks the downstack of the given branch.
// This includes the branch itself.
func (h *Handler) RestackDownstack(ctx context.Context, req *DownstackRequest) error {
opts := cmp.Or(req.Options, &Options{})
_, err := h.Restack(ctx, &Request{
Branch: req.Branch,
Scope: ScopeDownstack,
ContinueCommand: []string{"downstack", "restack"},
SkipConflicts: opts.SkipConflicts,
})
return err
}
51 changes: 49 additions & 2 deletions internal/handler/restack/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
type GitWorktree interface {
CurrentBranch(ctx context.Context) (string, error)
CheckoutBranch(ctx context.Context, branch string) error
RebaseAbort(ctx context.Context) error
RootDir() string
}

Expand Down Expand Up @@ -89,6 +90,13 @@ type Request struct {
// due to a conflict.
ContinueCommand []string // required

// SkipConflicts indicates that branches which cannot be rebased
// cleanly due to a conflict should be skipped and logged
// instead of interrupting the operation for conflict resolution.
//
// Descendants of a skipped branch are also skipped.
SkipConflicts bool

// Scope specifies which branches are affected by the restack operation.
//
// Defaults to ScopeBranch.
Expand All @@ -98,7 +106,10 @@ type Request struct {
// Options are optional parameters shared by the high-level restack
// entry points ([Handler.RestackBranch], [Handler.RestackStack],
// [Handler.RestackDownstack]).
type Options struct{}
type Options struct {
// SkipConflicts indicates that conflicting branches should be skipped.
SkipConflicts bool `help:"Skip branches that cannot be rebased due to conflicts"`
}

// Restack restacks one or more branches according to the request.
func (h *Handler) Restack(ctx context.Context, req *Request) (int, error) {
Expand Down Expand Up @@ -198,13 +209,40 @@ func (h *Handler) Restack(ctx context.Context, req *Request) (int, error) {
branchesToRestack = branchesToActuallyRestack

var restackCount int
var skipConflictCount int
loop:
for _, branch := range branchesToRestack {
if req.SkipConflicts {
if info, ok := branchGraph.Lookup(branch); ok {
if _, baseSkipped := skipped[info.Base]; baseSkipped {
h.Log.Warnf(
"%v: base branch %v was not restacked, skipping",
branch,
info.Base,
)
skipped[branch] = struct{}{}
continue loop
}
}
}

res, err := h.Service.Restack(ctx, branch)
if err != nil {
var rebaseErr *git.RebaseInterruptError
switch {
case errors.As(err, &rebaseErr):
if req.SkipConflicts &&
rebaseErr.Kind == git.RebaseInterruptConflict {
if err := h.Worktree.RebaseAbort(ctx); err != nil {
return 0, fmt.Errorf("abort rebase: %w", err)
}

skipped[branch] = struct{}{}
skipConflictCount++
h.Log.Warnf("%v: conflict, skipping", branch)
continue loop
}

// If the rebase is interrupted by a conflict,
// we'll resume by re-running this command.
return 0, h.Service.RebaseRescue(ctx, spice.RebaseRescueRequest{
Expand All @@ -231,9 +269,18 @@ loop:
restackCount++
}

if req.SkipConflicts && skipConflictCount > 0 {
h.Log.Warnf("Skipped %d branch(es) due to conflicts", skipConflictCount)
}

if requestBranchWT != "" && requestBranchWT != currentWT {
h.Log.Warnf("%v: checked out in another worktree (%v), not checking out here", req.Branch, requestBranchWT)
} else if restackCount > 0 {
} else if restackCount > 0 || skipConflictCount > 0 {
// Restacking checks out each branch as it goes,
// and aborting a conflicted rebase leaves us on the branch
// that was being restacked.
// In both cases, return to the starting branch
// so the user is never stranded on an intermediate branch.
if err := h.Worktree.CheckoutBranch(ctx, req.Branch); err != nil {
return 0, fmt.Errorf("checkout branch %v: %w", req.Branch, err)
}
Expand Down
Loading
Loading