Skip to content
Draft
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-20260514-074415.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submodule: ''gs stack restack --recurse-submodules'' restacks tracked submodules after the parent. Pair with ''spice.submodule.recurse=true'' (auto-set by ''gs repo init --recurse-submodules'') so the flag is not needed on every invocation.'
time: 2026-05-14T07:44:15.152979-04:00
39 changes: 24 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,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 @@ -322,12 +324,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 All @@ -345,6 +347,9 @@ Use --branch to rebase the stack of a different branch.
**Flags**

* `--branch=NAME`: Branch to restack the stack of
* `--[no-]recurse-submodules` ([:material-wrench:{ .middle title="spice.submodule.recurse" }](/cli/config.md#spicesubmodulerecurse)): Also restack tracked submodules

**Configuration**: [spice.submodule.recurse](/cli/config.md#spicesubmodulerecurse)

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

Expand Down Expand Up @@ -625,7 +630,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 @@ -643,13 +648,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 @@ -659,12 +666,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 @@ -1136,16 +1143,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
9 changes: 2 additions & 7 deletions internal/git/submodule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,9 @@ func TestSubmodules(t *testing.T) {
t.Run("SubmoduleWorktree", func(t *testing.T) {
subWt, err := parentWt.SubmoduleWorktree(ctx, "libs/core")
require.NoError(t, err)
// git rev-parse --show-toplevel returns forward slashes
// on all platforms, including Windows.
// Normalize both sides for comparison.
assert.Equal(t,
filepath.ToSlash(
filepath.Join(parentWt.RootDir(), "libs", "core"),
),
filepath.ToSlash(subWt.RootDir()),
filepath.ToSlash(filepath.Join(parentWt.RootDir(), "libs", "core")),
subWt.RootDir(),
)
})
}
Expand Down
51 changes: 49 additions & 2 deletions stack_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import (

"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/restack"
"go.abhg.dev/gs/internal/handler/submodule"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)

type stackRestackCmd struct {
Branch string `help:"Branch to restack the stack of" placeholder:"NAME" predictor:"trackedBranches"`
Branch string `help:"Branch to restack the stack of" placeholder:"NAME" predictor:"trackedBranches"`
RecurseSubmodules bool `name:"recurse-submodules" negatable:"" config:"submodule.recurse" help:"Also restack tracked submodules"`
}

func (*stackRestackCmd) Help() string {
Expand Down Expand Up @@ -81,12 +85,55 @@ func (cmd *stackRestackCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
cfg *spice.Config,
handler RestackHandler,
) error {
if err := verifyRestackFromTrunk(log, view, store, cmd.Branch, "stack"); err != nil {
return err
}

return handler.RestackStack(ctx, cmd.Branch, nil)
if err := handler.RestackStack(ctx, cmd.Branch, nil); err != nil {
return err
}

if !cmd.RecurseSubmodules {
return nil
}

var exclude []string
if cfg != nil {
exclude = cfg.SubmoduleExclusions()
}

return submodule.ForEachInitializedSubmodule(
ctx, wt, exclude, nil, log,
func(c *submodule.Context) error {
subCurrent, err := c.Worktree.CurrentBranch(ctx)
if err != nil {
log.Warn("Skipping submodule: cannot determine current branch",
"path", c.Path, "error", err)
return nil
}
log.Infof("Recursing restack into %s on %s",
c.Path, subCurrent)
subHandler := &restack.Handler{
Log: c.Log,
Worktree: c.Worktree,
Store: c.Store,
Service: c.Service,
}
if _, err := subHandler.Restack(ctx, &restack.Request{
Branch: subCurrent,
ContinueCommand: []string{"stack", "restack"},
Scope: restack.ScopeStack,
}); err != nil {
return fmt.Errorf(
"submodule %s restack: %w", c.Path, err,
)
}
return nil
},
)
}
4 changes: 3 additions & 1 deletion testdata/help/stack_restack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ ensuring a linear history.
Use --branch to rebase the stack of a different branch.

Flags:
--branch=NAME Branch to restack the stack of
--branch=NAME Branch to restack the stack of
--[no-]recurse-submodules Also restack tracked submodules (🔧
spice.submodule.recurse)

Global Flags:
-h, --help Show help for the command
Expand Down
38 changes: 38 additions & 0 deletions testdata/script/stack_restack_recurse.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 'gs stack restack --recurse-submodules' restacks the parent's stack
# and then the sub's stack too.

as 'Test <test@example.com>'
at '2026-03-11T15:00:00Z'

# Set up a gs-initialized submodule.
cd sub-repo
git init
git commit --allow-empty -m 'Initial'
gs repo init
git add a.txt
gs branch create sub-feat -m 'Sub feat'

# Set up parent with recurse-submodules at init time.
cd $WORK/repo
git init
git commit --allow-empty -m 'Initial'
git -c protocol.file.allow=always submodule add $WORK/sub-repo libs/core
git commit -m 'Add submodule'
gs repo init --trunk=main --recurse-submodules

# Switch sub to sub-feat; create a parent branch.
cd libs/core
git checkout sub-feat
cd $WORK/repo
git add parent.txt
gs branch create feat-x -m 'Add parent'

# Validate that gs stack restack --recurse-submodules runs without
# error even when there's nothing to restack.
gs stack restack --recurse-submodules

-- sub-repo/a.txt --
sub a
-- repo/parent.txt --
parent
-- repo/.keep --
Loading