diff --git a/.changes/unreleased/Added-20260609-045211.yaml b/.changes/unreleased/Added-20260609-045211.yaml
new file mode 100644
index 000000000..646009f9e
--- /dev/null
+++ b/.changes/unreleased/Added-20260609-045211.yaml
@@ -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
diff --git a/branch_restack.go b/branch_restack.go
index 106b698d6..df432f02c 100644
--- a/branch_restack.go
+++ b/branch_restack.go
@@ -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 {
@@ -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,
+ },
})
}
diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md
index 691bc2fb2..c54728863 100644
--- a/doc/includes/cli-reference.md
+++ b/doc/includes/cli-reference.md
@@ -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]
```
:material-tag:{ title="Released in version" }[v0.16.0](/changelog.md#v0.16.0)
@@ -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}
@@ -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}
@@ -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
@@ -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
@@ -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}
diff --git a/downstack_restack.go b/downstack_restack.go
index f3bffb066..87b98a267 100644
--- a/downstack_restack.go
+++ b/downstack_restack.go
@@ -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 {
@@ -47,5 +49,8 @@ func (cmd *downstackRestackCmd) Run(
return handler.RestackDownstack(ctx, &restack.DownstackRequest{
Branch: cmd.Branch,
+ Options: &restack.Options{
+ SkipConflicts: cmd.SkipConflicts,
+ },
})
}
diff --git a/internal/handler/restack/branch.go b/internal/handler/restack/branch.go
index 8e4269600..88a0a9a7a 100644
--- a/internal/handler/restack/branch.go
+++ b/internal/handler/restack/branch.go
@@ -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 {
@@ -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
}
diff --git a/internal/handler/restack/branch_test.go b/internal/handler/restack/branch_test.go
index 011e64518..68f1805f5 100644
--- a/internal/handler/restack/branch_test.go
+++ b/internal/handler/restack/branch_test.go
@@ -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)
diff --git a/internal/handler/restack/downstack.go b/internal/handler/restack/downstack.go
index 3dbb3a540..86662f60f 100644
--- a/internal/handler/restack/downstack.go
+++ b/internal/handler/restack/downstack.go
@@ -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 {
@@ -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
}
diff --git a/internal/handler/restack/handler.go b/internal/handler/restack/handler.go
index 0be7b49a8..8382b9b49 100644
--- a/internal/handler/restack/handler.go
+++ b/internal/handler/restack/handler.go
@@ -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
}
@@ -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.
@@ -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) {
@@ -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{
@@ -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)
}
diff --git a/internal/handler/restack/handler_test.go b/internal/handler/restack/handler_test.go
index fbd3440fc..733597d91 100644
--- a/internal/handler/restack/handler_test.go
+++ b/internal/handler/restack/handler_test.go
@@ -473,6 +473,169 @@ func TestHandler_Restack(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 0, count)
})
+
+ t.Run("SkipConflicts", func(t *testing.T) {
+ t.Run("Conflict", func(t *testing.T) {
+ var logBuffer bytes.Buffer
+ log := silog.New(&logBuffer, nil)
+ ctrl := gomock.NewController(t)
+
+ rebaseErr := &git.RebaseInterruptError{
+ Kind: git.RebaseInterruptConflict,
+ }
+
+ mockService := NewMockService(ctrl)
+ mockService.EXPECT().
+ BranchGraph(gomock.Any(), gomock.Any()).
+ Return(newBranchGraphBuilder("main").
+ Branch("feature", "main").
+ Branch("feature2", "feature").
+ Branch("other", "main").
+ Build(t), nil)
+ mockService.EXPECT().
+ Restack(gomock.Any(), "feature").
+ Return(nil, rebaseErr)
+ mockService.EXPECT().
+ Restack(gomock.Any(), "other").
+ Return(&spice.RestackResponse{Base: "main"}, nil)
+
+ mockWorktree := NewMockGitWorktree(ctrl)
+ mockWorktree.EXPECT().
+ RootDir().
+ Return(t.TempDir())
+ mockWorktree.EXPECT().
+ RebaseAbort(gomock.Any()).
+ Return(nil)
+ mockWorktree.EXPECT().
+ CheckoutBranch(gomock.Any(), "main").
+ Return(nil)
+
+ handler := &Handler{
+ Log: log,
+ Worktree: mockWorktree,
+ Store: statetest.NewMemoryStore(t, "main", "", log),
+ Service: mockService,
+ }
+
+ count, err := handler.Restack(t.Context(), &Request{
+ Branch: "main",
+ ContinueCommand: []string{"false"},
+ Scope: ScopeUpstackExclusive,
+ SkipConflicts: true,
+ })
+
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, count, 1)
+ assert.Contains(t, logBuffer.String(), "feature: conflict, skipping")
+ assert.Contains(
+ t,
+ logBuffer.String(),
+ "feature2: base branch feature was not restacked, skipping",
+ )
+ assert.Contains(t, logBuffer.String(), "other: restacked on main")
+ assert.Contains(t, logBuffer.String(), "Skipped 1 branch(es) due to conflicts")
+ })
+
+ t.Run("AllConflict", func(t *testing.T) {
+ // When every branch conflicts and is skipped,
+ // the starting branch is still checked out
+ // so the user is not stranded on a conflicting branch.
+ var logBuffer bytes.Buffer
+ log := silog.New(&logBuffer, nil)
+ ctrl := gomock.NewController(t)
+
+ rebaseErr := &git.RebaseInterruptError{
+ Kind: git.RebaseInterruptConflict,
+ }
+
+ 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, rebaseErr)
+
+ mockWorktree := NewMockGitWorktree(ctrl)
+ mockWorktree.EXPECT().
+ RootDir().
+ Return(t.TempDir())
+ mockWorktree.EXPECT().
+ RebaseAbort(gomock.Any()).
+ Return(nil)
+ mockWorktree.EXPECT().
+ CheckoutBranch(gomock.Any(), "main").
+ Return(nil)
+
+ handler := &Handler{
+ Log: log,
+ Worktree: mockWorktree,
+ Store: statetest.NewMemoryStore(t, "main", "", log),
+ Service: mockService,
+ }
+
+ count, err := handler.Restack(t.Context(), &Request{
+ Branch: "main",
+ ContinueCommand: []string{"false"},
+ Scope: ScopeUpstackExclusive,
+ SkipConflicts: true,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, 0, count)
+ assert.Contains(t, logBuffer.String(), "feature: conflict, skipping")
+ assert.Contains(t, logBuffer.String(), "Skipped 1 branch(es) due to conflicts")
+ })
+
+ t.Run("DeliberateInterrupt", func(t *testing.T) {
+ var logBuffer bytes.Buffer
+ log := silog.New(&logBuffer, nil)
+ ctrl := gomock.NewController(t)
+
+ rebaseErr := &git.RebaseInterruptError{
+ Kind: git.RebaseInterruptDeliberate,
+ }
+
+ 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, rebaseErr)
+ mockService.EXPECT().
+ RebaseRescue(gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ mockWorktree := NewMockGitWorktree(ctrl)
+ mockWorktree.EXPECT().
+ RootDir().
+ Return(t.TempDir())
+
+ handler := &Handler{
+ Log: log,
+ Worktree: mockWorktree,
+ Store: statetest.NewMemoryStore(t, "main", "", log),
+ Service: mockService,
+ }
+
+ count, err := handler.Restack(t.Context(), &Request{
+ Branch: "feature",
+ ContinueCommand: []string{"false"},
+ Scope: ScopeBranch,
+ SkipConflicts: true,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, 0, count)
+ assert.NotContains(t, logBuffer.String(), "conflict, skipping")
+ assert.NotContains(t, logBuffer.String(), "Skipped")
+ })
+ })
}
func TestHandler_Restack_trunk(t *testing.T) {
diff --git a/internal/handler/restack/mocks_test.go b/internal/handler/restack/mocks_test.go
index 8edc97530..305c2c088 100644
--- a/internal/handler/restack/mocks_test.go
+++ b/internal/handler/restack/mocks_test.go
@@ -70,6 +70,20 @@ func (mr *MockGitWorktreeMockRecorder) CurrentBranch(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentBranch", reflect.TypeOf((*MockGitWorktree)(nil).CurrentBranch), ctx)
}
+// RebaseAbort mocks base method.
+func (m *MockGitWorktree) RebaseAbort(ctx context.Context) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "RebaseAbort", ctx)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// RebaseAbort indicates an expected call of RebaseAbort.
+func (mr *MockGitWorktreeMockRecorder) RebaseAbort(ctx any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RebaseAbort", reflect.TypeOf((*MockGitWorktree)(nil).RebaseAbort), ctx)
+}
+
// RootDir mocks base method.
func (m *MockGitWorktree) RootDir() string {
m.ctrl.T.Helper()
diff --git a/internal/handler/restack/stack.go b/internal/handler/restack/stack.go
index c3f9e85a2..388a40a26 100644
--- a/internal/handler/restack/stack.go
+++ b/internal/handler/restack/stack.go
@@ -1,6 +1,9 @@
package restack
-import "context"
+import (
+ "cmp"
+ "context"
+)
// StackRequest is a request to restack all branches in a stack.
type StackRequest struct {
@@ -14,10 +17,12 @@ type StackRequest struct {
// This includes all upstack and downtrack branches,
// as well as the branch itself.
func (h *Handler) RestackStack(ctx context.Context, req *StackRequest) error {
+ opts := cmp.Or(req.Options, &Options{})
_, err := h.Restack(ctx, &Request{
Branch: req.Branch,
Scope: ScopeStack,
ContinueCommand: []string{"stack", "restack"},
+ SkipConflicts: opts.SkipConflicts,
})
return err
}
diff --git a/internal/handler/restack/upstack.go b/internal/handler/restack/upstack.go
index 472ea0a3c..771b39756 100644
--- a/internal/handler/restack/upstack.go
+++ b/internal/handler/restack/upstack.go
@@ -29,6 +29,7 @@ func (h *Handler) RestackUpstack(ctx context.Context, req *UpstackRequest) error
Branch: req.Branch,
Scope: ScopeUpstack,
ContinueCommand: []string{"upstack", "restack"},
+ SkipConflicts: opts.SkipConflicts,
}
if opts.SkipStart {
restackReq.Scope = ScopeUpstackExclusive
diff --git a/repo_restack.go b/repo_restack.go
index c86e1ba01..cfa015e85 100644
--- a/repo_restack.go
+++ b/repo_restack.go
@@ -12,16 +12,20 @@ import (
"go.abhg.dev/gs/internal/text"
)
-type repoRestackCmd struct{}
+type repoRestackCmd struct {
+ SkipConflicts bool `help:"Skip branches that cannot be rebased due to conflicts"`
+}
func (*repoRestackCmd) Help() string {
return text.Dedent(`
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.
`)
}
-func (*repoRestackCmd) Run(
+func (cmd *repoRestackCmd) Run(
ctx context.Context,
log *silog.Logger,
wt *git.Worktree,
@@ -48,20 +52,21 @@ func (*repoRestackCmd) Run(
Branch: store.Trunk(),
Scope: restack.ScopeUpstackExclusive,
ContinueCommand: []string{"repo", "restack"},
+ SkipConflicts: cmd.SkipConflicts,
})
if err != nil {
return err
}
+ if err := wt.CheckoutBranch(ctx, currentBranch); err != nil {
+ return fmt.Errorf("checkout %v: %w", currentBranch, err)
+ }
+
if count == 0 {
log.Infof("Nothing to restack: no tracked branches available")
return nil
}
- if err := wt.CheckoutBranch(ctx, currentBranch); err != nil {
- return fmt.Errorf("checkout %v: %w", currentBranch, err)
- }
-
log.Infof("Restacked %d branches", count)
return nil
}
diff --git a/stack_restack.go b/stack_restack.go
index 4bd7b32f9..d63c4c197 100644
--- a/stack_restack.go
+++ b/stack_restack.go
@@ -16,6 +16,8 @@ import (
type stackRestackCmd struct {
Branch string `help:"Branch to restack the stack of" placeholder:"NAME" predictor:"trackedBranches"`
+
+ SkipConflicts bool `help:"Skip branches that cannot be rebased due to conflicts"`
}
func (*stackRestackCmd) Help() string {
@@ -24,6 +26,8 @@ func (*stackRestackCmd) Help() string {
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.
`)
}
@@ -91,5 +95,8 @@ func (cmd *stackRestackCmd) Run(
return handler.RestackStack(ctx, &restack.StackRequest{
Branch: cmd.Branch,
+ Options: &restack.Options{
+ SkipConflicts: cmd.SkipConflicts,
+ },
})
}
diff --git a/testdata/help/branch_restack.txt b/testdata/help/branch_restack.txt
index 2a9a3b393..2e8504666 100644
--- a/testdata/help/branch_restack.txt
+++ b/testdata/help/branch_restack.txt
@@ -6,7 +6,8 @@ The current branch will be rebased onto its base, ensuring a linear history.
Use --branch to target a different branch.
Flags:
- --branch=NAME Branch to restack
+ --branch=NAME Branch to restack
+ --skip-conflicts Skip branches that cannot be rebased due to conflicts
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/downstack_restack.txt b/testdata/help/downstack_restack.txt
index 0cd3860bb..07ea72c90 100644
--- a/testdata/help/downstack_restack.txt
+++ b/testdata/help/downstack_restack.txt
@@ -8,7 +8,8 @@ their respective bases, ensuring a linear history.
Use --branch to start at a different branch.
Flags:
- --branch=NAME Branch to restack the downstack of
+ --branch=NAME Branch to restack the downstack of
+ --skip-conflicts Skip branches that cannot be rebased due to conflicts
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/repo_restack.txt b/testdata/help/repo_restack.txt
index 02bb09603..9b2a7cc1d 100644
--- a/testdata/help/repo_restack.txt
+++ b/testdata/help/repo_restack.txt
@@ -1,10 +1,15 @@
-Usage: gs repo (r) restack (r)
+Usage: gs repo (r) restack (r) [flags]
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
+
Global Flags:
-h, --help Show help for the command
--version Print version information and quit
diff --git a/testdata/help/stack_restack.txt b/testdata/help/stack_restack.txt
index 5b7b376d5..7d9aff12a 100644
--- a/testdata/help/stack_restack.txt
+++ b/testdata/help/stack_restack.txt
@@ -7,8 +7,11 @@ 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
+ --branch=NAME Branch to restack the stack of
+ --skip-conflicts Skip branches that cannot be rebased due to conflicts
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/upstack_restack.txt b/testdata/help/upstack_restack.txt
index 4ee4a6d73..84571a351 100644
--- a/testdata/help/upstack_restack.txt
+++ b/testdata/help/upstack_restack.txt
@@ -11,8 +11,9 @@ Use --skip-start to skip the starting branch, but still rebase all branches
above it.
Flags:
- --skip-start Do not restack the starting branch
- --branch=NAME Branch to restack the upstack of
+ --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
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/script/restack_skip_conflicts.txt b/testdata/script/restack_skip_conflicts.txt
new file mode 100644
index 000000000..ca6780e60
--- /dev/null
+++ b/testdata/script/restack_skip_conflicts.txt
@@ -0,0 +1,65 @@
+# 'repo restack --skip-conflicts' skips conflicting branches
+# and continues restacking clean branches.
+
+as 'Test '
+at '2026-06-09T12:00:00Z'
+
+mkdir repo
+cd repo
+git init
+git add init.txt
+git commit -m 'Initial commit'
+gs repo init
+
+# feature will conflict with the later trunk change.
+cp $WORK/extra/init.feature.txt init.txt
+git add init.txt
+gs branch create feature -m 'Feature changes init'
+
+# feature2 is upstack from feature and should be skipped.
+git add feature2.txt
+gs branch create feature2 -m 'Add feature2'
+
+# other is independent from feature and should restack cleanly.
+gs trunk
+git add other.txt
+gs branch create other -m 'Add other'
+
+# Advance trunk with a conflicting change.
+gs trunk
+cp $WORK/extra/init.main.txt init.txt
+git add init.txt
+git commit -m 'Main changes init'
+
+# Start from a branch that will be skipped.
+# The command must restore it after processing the repository.
+git checkout feature2
+
+gs repo restack --skip-conflicts
+stderr 'feature: conflict, skipping'
+stderr 'feature2: base branch feature was not restacked, skipping'
+stderr 'other: restacked on main'
+stderr 'Skipped 1 branch\(es\) due to conflicts'
+stderr 'Restacked 1 branches'
+
+# The clean branch restacked, while the conflicting stack did not.
+git merge-base --is-ancestor main other
+! git merge-base --is-ancestor main feature
+git merge-base --is-ancestor feature feature2
+
+git branch --show-current
+stdout '^feature2$'
+
+git status --porcelain
+! stdout '.'
+
+-- repo/init.txt --
+initial init
+-- repo/feature2.txt --
+feature2
+-- repo/other.txt --
+other
+-- extra/init.feature.txt --
+feature init
+-- extra/init.main.txt --
+main init