diff --git a/.changes/unreleased/Added-20260514-074415.yaml b/.changes/unreleased/Added-20260514-074415.yaml new file mode 100644 index 00000000..c85548c9 --- /dev/null +++ b/.changes/unreleased/Added-20260514-074415.yaml @@ -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 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 2acadd6a..37f06e78 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -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. @@ -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} @@ -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} @@ -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: @@ -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 @@ -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} @@ -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} diff --git a/internal/git/submodule_test.go b/internal/git/submodule_test.go index 715dd0a0..360454a3 100644 --- a/internal/git/submodule_test.go +++ b/internal/git/submodule_test.go @@ -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(), ) }) } diff --git a/stack_restack.go b/stack_restack.go index 7d7b4d68..0fe871e8 100644 --- a/stack_restack.go +++ b/stack_restack.go @@ -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 { @@ -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 + }, + ) } diff --git a/testdata/help/stack_restack.txt b/testdata/help/stack_restack.txt index 5b7b376d..d1a32145 100644 --- a/testdata/help/stack_restack.txt +++ b/testdata/help/stack_restack.txt @@ -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 diff --git a/testdata/script/stack_restack_recurse.txt b/testdata/script/stack_restack_recurse.txt new file mode 100644 index 00000000..5dd29f9d --- /dev/null +++ b/testdata/script/stack_restack_recurse.txt @@ -0,0 +1,38 @@ +# 'gs stack restack --recurse-submodules' restacks the parent's stack +# and then the sub's stack too. + +as 'Test ' +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 --