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-074144.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submodule: ''gs repo init'' offers to initialize tracked submodules recursively (--recurse-submodules/--no-recurse-submodules), resolving each sub''s trunk via .gitmodules branch, HEAD upstream, or the guesser. On opt-in, ''spice.submodule.recurse=true'' is written so daily ops will recurse.'
time: 2026-05-14T07:41:44.8119-04:00
37 changes: 22 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ and untrack all branches.
* `--remote=NAME`: Name of the remote to push submitted branches to
* `--upstream=NAME`: Name of the remote to open change requests against
* `--reset`: Forget all information about the repository
* `--[no-]recurse-submodules`: Also initialize tracked submodules. Prompts when unset and submodules are present.

### git-spice repo sync {#gs-repo-sync}

Expand Down Expand Up @@ -310,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 @@ -321,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 Down Expand Up @@ -624,7 +627,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 @@ -642,13 +645,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 @@ -658,12 +663,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 @@ -1135,16 +1140,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
52 changes: 50 additions & 2 deletions internal/git/submodule.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ type Submodule struct {

// URL is the configured remote URL.
URL string

// Branch is the upstream-tracking branch declared via
// `submodule.<name>.branch` in .gitmodules, or "" if unset.
// Used by `git submodule update --remote` and consumed by
// git-spice as a trunk hint during recursive init.
Branch string
}

// Submodules lists all submodules in the worktree.
Expand Down Expand Up @@ -84,9 +90,19 @@ func submoduleFromConfigKey(
url = "" // URL may be missing for local submodules.
}

branchKey := "submodule." + name + ".branch"
branch, err := w.gitCmd(ctx,
"config", "--file", ".gitmodules",
"--get", branchKey,
).OutputChomp()
if err != nil {
branch = "" // .gitmodules branch is optional.
}

return Submodule{
Path: strings.TrimSpace(path),
URL: strings.TrimSpace(url),
Path: strings.TrimSpace(path),
URL: strings.TrimSpace(url),
Branch: strings.TrimSpace(branch),
}, nil
}

Expand Down Expand Up @@ -257,6 +273,38 @@ func (w *Worktree) SubmoduleHasGsStore(
return true, nil
}

// GitConfigGet reads a single value from the worktree's local
// git-config. Returns the empty string and a nil error if unset.
func (w *Worktree) GitConfigGet(
ctx context.Context, key string,
) (string, error) {
out, err := w.gitCmd(ctx,
"config", "--get", key,
).OutputChomp()
if err != nil {
// `git config --get` exits 1 when the key is missing;
// treat that as the empty string.
var exitErr *xec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return "", nil
}
return "", fmt.Errorf("git config --get %s: %w", key, err)
}
return out, nil
}

// GitConfigSet writes a value to the worktree's local git-config.
func (w *Worktree) GitConfigSet(
ctx context.Context, key, value string,
) error {
if err := w.gitCmd(ctx,
"config", "--local", key, value,
).Run(); err != nil {
return fmt.Errorf("git config %s: %w", key, err)
}
return nil
}

// AddUpdate stages updates to all tracked files in the worktree
// (`git add -u`). Untracked files are not affected.
func (w *Worktree) AddUpdate(ctx context.Context) error {
Expand Down
9 changes: 7 additions & 2 deletions internal/git/submodule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ 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,
joinSlash(parentWt.RootDir(), "libs", "core"),
joinSlash(subWt.RootDir()),
filepath.ToSlash(
filepath.Join(parentWt.RootDir(), "libs", "core"),
),
filepath.ToSlash(subWt.RootDir()),
)
})
}
Expand Down
158 changes: 157 additions & 1 deletion repo_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
Expand All @@ -27,7 +28,8 @@ type repoInitCmd struct {
Remote string `placeholder:"NAME" predictor:"remotes" help:"Name of the remote to push submitted branches to"`
Upstream string `placeholder:"NAME" predictor:"remotes" help:"Name of the remote to open change requests against"`

Reset bool `help:"Forget all information about the repository"`
Reset bool `help:"Forget all information about the repository"`
Recurse *bool `name:"recurse-submodules" negatable:"" help:"Also initialize tracked submodules. Prompts when unset and submodules are present."`
}

func (*repoInitCmd) Help() string {
Expand Down Expand Up @@ -110,9 +112,163 @@ func (cmd *repoInitCmd) Run(
}

log.Info("Initialized repository", "trunk", cmd.Trunk)

if err := cmd.maybeRecurseSubmodules(ctx, log, view, wt); err != nil {
// Recursive init is opportunistic: surface as a warning,
// not a hard failure of the parent init.
log.Warn("Recursive submodule init failed", "error", err)
}

return nil
}

// maybeRecurseSubmodules optionally initializes git-spice in each
// tracked submodule and persists `spice.submodule.recurse=true` for
// future ops to pick up.
func (cmd *repoInitCmd) maybeRecurseSubmodules(
ctx context.Context,
log *silog.Logger,
view ui.View,
wt *git.Worktree,
) error {
subs, err := wt.Submodules(ctx)
if err != nil {
return fmt.Errorf("list submodules: %w", err)
}
if len(subs) == 0 {
return nil
}

// Resolve whether to recurse:
// - explicit flag wins;
// - interactive: prompt with default Yes;
// - non-interactive: default Yes for non-interactive scripts.
var recurse bool
switch {
case cmd.Recurse != nil:
recurse = *cmd.Recurse
case ui.Interactive(view):
recurse = true
prompt := ui.NewConfirm().
WithTitle(fmt.Sprintf(
"Initialize %d submodule(s) with git-spice too?",
len(subs))).
WithDescription(
"You can rerun 'gs repo init --no-recurse-submodules' to skip later.").
WithValue(&recurse)
if err := ui.Run(view, prompt); err != nil {
return fmt.Errorf("submodule prompt: %w", err)
}
default:
recurse = true
}

if !recurse {
return nil
}

for _, sub := range subs {
if err := cmd.initOneSubmodule(ctx, log, view, wt, sub); err != nil {
log.Warn("Submodule init failed",
"path", sub.Path, "error", err)
continue
}
}

// Persist the recurse preference for future ops, if unset.
if err := writeRecurseConfigIfUnset(ctx, wt, log); err != nil {
log.Warn("Could not set spice.submodule.recurse",
"error", err)
}

return nil
}

// writeRecurseConfigIfUnset writes spice.submodule.recurse=true to
// the worktree's local git-config only when the key is absent so
// an explicit user opt-out is never overridden.
func writeRecurseConfigIfUnset(
ctx context.Context, wt *git.Worktree, log *silog.Logger,
) error {
if existing, err := wt.GitConfigGet(ctx, "spice.submodule.recurse"); err == nil && existing != "" {
// Already explicitly set — never override.
return nil
}
if err := wt.GitConfigSet(ctx,
"spice.submodule.recurse", "true"); err != nil {
return err
}
log.Info(
"Submodule recursion enabled. " +
"Use 'git config spice.submodule.recurse false' to opt out.",
)
return nil
}

// initOneSubmodule initializes git-spice inside a single submodule.
// Trunk is resolved by precedence:
// 1. submodule.<name>.branch in .gitmodules (tracking branch);
// 2. submodule HEAD's upstream branch;
// 3. submodule's repo guesser (interactive prompt or auto-detect).
func (cmd *repoInitCmd) initOneSubmodule(
ctx context.Context,
log *silog.Logger,
view ui.View,
parentWT *git.Worktree,
sub git.Submodule,
) error {
has, err := parentWT.SubmoduleHasGsStore(ctx, sub.Path)
if err != nil {
return fmt.Errorf("check gs store: %w", err)
}
if has {
log.Info("Submodule already initialized",
"path", sub.Path)
return nil
}

subWT, err := parentWT.SubmoduleWorktree(ctx, sub.Path)
if err != nil {
return fmt.Errorf("open submodule worktree: %w", err)
}
subRepo := subWT.Repository()

trunk, src := resolveSubmoduleTrunk(ctx, subWT, sub)
log.Info("Initializing submodule",
"path", sub.Path,
"trunk", trunk,
"source", src)

return (&repoInitCmd{
Trunk: trunk,
Reset: cmd.Reset,
}).Run(ctx, log, view, subRepo, subWT)
}

// resolveSubmoduleTrunk picks the trunk branch for a submodule init
// based on .gitmodules, HEAD's upstream, or guesser fallback.
func resolveSubmoduleTrunk(
ctx context.Context,
subWT *git.Worktree,
sub git.Submodule,
) (trunk string, source string) {
if sub.Branch != "" && subWT.Repository().BranchExists(ctx, sub.Branch) {
return sub.Branch, ".gitmodules"
}
cur, err := subWT.CurrentBranch(ctx)
if err == nil {
if upstream, err := subWT.Repository().BranchUpstream(ctx, cur); err == nil {
// upstream is e.g. "origin/main"; strip the remote prefix.
if _, branch, ok := strings.Cut(upstream, "/"); ok {
return branch, "HEAD upstream"
}
}
return cur, "current branch"
}
// Falls back to empty so the child repoInitCmd guesses interactively.
return "", "guess"
}

// repoInitRemoteGuesser guesses remotes for repository initialization.
type repoInitRemoteGuesser interface {
GuessUpstreamRemote(context.Context, spice.GitRepository) (string, error)
Expand Down
11 changes: 7 additions & 4 deletions testdata/help/repo_init.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ stacked on the old trunk will be updated to point to the new trunk.
Re-run with --reset to discard all stored information and untrack all branches.

Flags:
--trunk=BRANCH Name of the trunk branch
--remote=NAME Name of the remote to push submitted branches to
--upstream=NAME Name of the remote to open change requests against
--reset Forget all information about the repository
--trunk=BRANCH Name of the trunk branch
--remote=NAME Name of the remote to push submitted branches to
--upstream=NAME Name of the remote to open change requests
against
--reset Forget all information about the repository
--[no-]recurse-submodules Also initialize tracked submodules. Prompts when
unset and submodules are present.

Global Flags:
-h, --help Show help for the command
Expand Down
Loading
Loading