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-072440.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submodule: gs branch fold merges submodule associations across the folded and base branches; conflicts prompt interactively or accept ''--module-branch=<path>=<branch>''.'
time: 2026-05-14T07:24:40.075455-04:00
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260514-072626.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submodule: gs branch checkout (and friends) now switch tracked submodules to their recorded branches transactionally — on conflict, parent and already-switched submodules roll back to their original HEADs.'
time: 2026-05-14T07:26:26.543871-04:00
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260514-072627.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submodule: gs branch create inherits recorded submodule associations from the parent branch as a baseline.'
time: 2026-05-14T07:26:27.764587-04:00
7 changes: 5 additions & 2 deletions branch_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,12 @@ func (cmd *branchCreateCmd) Run(
}

// Record submodule associations if a commit was made.
// Inherit from the parent branch as a baseline so a fresh child
// is consistent with its parent's submodule pinning unless the
// user has explicitly moved a submodule.
if cmd.Commit {
if err := submoduleTracker.RecordBranchState(
ctx, branchName,
if err := submoduleTracker.RecordWithInheritance(
ctx, branchName, cmd.Target,
); err != nil {
log.Warn(
"Could not record submodule associations",
Expand Down
71 changes: 70 additions & 1 deletion branch_fold.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"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"
Expand All @@ -15,7 +16,8 @@ import (
)

type branchFoldCmd struct {
Branch string `placeholder:"NAME" help:"Name of the branch" predictor:"trackedBranches"`
Branch string `placeholder:"NAME" help:"Name of the branch" predictor:"trackedBranches"`
ModuleBranch map[string]string `name:"module-branch" placeholder:"PATH=BRANCH" help:"Per-submodule branch override for fold conflicts (repeatable)"`
}

func (*branchFoldCmd) Help() string {
Expand All @@ -38,6 +40,7 @@ func (cmd *branchFoldCmd) Run(
store *state.Store,
svc *spice.Service,
checkoutHandler CheckoutHandler,
submoduleApplier SubmoduleApplier,
) error {
if cmd.Branch == "" {
currentBranch, err := wt.CurrentBranch(ctx)
Expand Down Expand Up @@ -101,8 +104,74 @@ func (cmd *branchFoldCmd) Run(
return fmt.Errorf("peel to commit: %w", err)
}

// Merge submodule associations from child into base.
// The child branch is being folded away; its sub-branch records
// should win when they differ from the base's, but conflicting
// per-sub records require explicit resolution.
type submoduleApplierWithMerge interface {
MergeAssociationsForFold(
ctx context.Context, req submodule.MergeFoldRequest,
) (map[string]string, error)
}
merge, _ := submoduleApplier.(submoduleApplierWithMerge)
var resolvedSubs map[string]string
if merge != nil && b.Base != store.Trunk() {
// Trunk is not tracked in the store; nothing to merge against.
var resolveFn func(submodule.FoldConflict) (string, error)
if ui.Interactive(view) {
resolveFn = func(c submodule.FoldConflict) (string, error) {
var pick string
prompt := ui.NewSelect[string]().
WithValue(&pick).
WithOptions(
ui.SelectOption[string]{
Label: c.ChildBranch,
Value: c.ChildBranch,
},
ui.SelectOption[string]{
Label: c.BaseBranch,
Value: c.BaseBranch,
},
).
WithTitle(fmt.Sprintf(
"Submodule %s: pick branch for %s",
c.Path, b.Base)).
WithDescription(fmt.Sprintf(
"Folding %s (records %s) into %s (records %s)",
cmd.Branch, c.ChildBranch,
b.Base, c.BaseBranch))
if err := ui.Run(view, prompt); err != nil {
return "", err
}
return pick, nil
}
}
var err error
resolvedSubs, err = merge.MergeAssociationsForFold(ctx, submodule.MergeFoldRequest{
Base: b.Base,
Child: cmd.Branch,
ModuleBranch: cmd.ModuleBranch,
Resolve: resolveFn,
})
if err != nil {
return fmt.Errorf("merge submodule associations: %w", err)
}
}

tx := store.BeginBranchTx()

// Persist the merged submodule associations onto the base.
if len(resolvedSubs) > 0 {
if err := tx.Upsert(ctx, state.UpsertRequest{
Name: b.Base,
Submodules: resolvedSubs,
}); err != nil {
return fmt.Errorf(
"set submodule associations on %v: %w", b.Base, err,
)
}
}

// Change the base of all branches above us
// to the base of the branch we are folding.
aboves, err := svc.ListAbove(ctx, cmd.Branch)
Expand Down
34 changes: 32 additions & 2 deletions branch_onto.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/onto"
"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"
Expand Down Expand Up @@ -116,14 +117,43 @@ func (cmd *branchOntoCmd) AfterApply(

func (cmd *branchOntoCmd) Run(
ctx context.Context,
log *silog.Logger,
wt *git.Worktree,
handler OntoHandler,
submoduleApplier SubmoduleApplier,
) error {
return handler.BranchOnto(ctx, &onto.BranchRequest{
// Snapshot parent HEAD before the checkout so a submodule apply
// failure rolls back cleanly. branch onto bypasses checkout.Handler
// (its VerifyRestacked path is not appropriate post-BranchOnto),
// so we apply submodule associations directly after the handler runs.
parentSnap, snapErr := wt.SnapshotHead(ctx)
if snapErr != nil {
log.Warn("Could not snapshot HEAD before checkout; "+
"submodule rollback disabled",
"error", snapErr)
}

if err := handler.BranchOnto(ctx, &onto.BranchRequest{
Branch: cmd.Branch,
Onto: cmd.Onto,
Restack: cmd.Restack,
ContinueCommand: cmd.continueCommand(),
})
}); err != nil {
return err
}

if err := submoduleApplier.ApplyAssociations(ctx, cmd.Branch); err != nil {
if parentSnap != nil {
if rerr := wt.RestoreHead(ctx, parentSnap); rerr != nil {
log.Warn("Parent rollback failed after submodule conflict",
"target", parentSnap.Hash,
"error", rerr)
}
}
return fmt.Errorf("apply submodules: %w", err)
}

return nil
}

func (cmd *branchOntoCmd) continueCommand() []string {
Expand Down
9 changes: 9 additions & 0 deletions branch_submodule.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,19 @@ import (
// for the current worktree state.
type SubmoduleTracker interface {
RecordBranchState(ctx context.Context, branch string) error
RecordWithInheritance(ctx context.Context, branch, parentBranch string) error
}

var _ SubmoduleTracker = (*submodule.Tracker)(nil)

// SubmoduleApplier switches tracked submodules to the branches
// recorded for a parent branch, transactionally.
type SubmoduleApplier interface {
ApplyAssociations(ctx context.Context, parentBranch string) error
}

var _ SubmoduleApplier = (*submodule.Applier)(nil)

type branchSubmoduleCmd struct {
List branchSubmoduleListCmd `cmd:"" aliases:"ls" help:"List submodule branch associations"`
Repoint branchSubmoduleRepointCmd `cmd:"" help:"Change submodule branch association for the current branch"`
Expand Down
37 changes: 22 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,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 +323,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 +626,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 +644,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 +662,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 @@ -914,6 +918,7 @@ Use the --branch flag to target a different branch.
**Flags**

* `--branch=NAME`: Name of the branch
* `--module-branch=PATH=BRANCH`: Per-submodule branch override for fold conflicts (repeatable)

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

Expand Down Expand Up @@ -1133,16 +1138,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: 44 additions & 8 deletions internal/handler/checkout/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"go.abhg.dev/gs/internal/spice/state"
)

//go:generate mockgen -destination mocks_test.go -package checkout -typed . GitRepository,GitWorktree,TrackHandler,Service,Store
//go:generate mockgen -destination mocks_test.go -package checkout -typed . GitRepository,GitWorktree,TrackHandler,Service,Store,SubmoduleApplier

// Options defines options for checking out a branch.
// These turn into command line flags, so be mindful of what you add here.
Expand All @@ -40,6 +40,14 @@ type Store interface {
type GitWorktree interface {
DetachHead(ctx context.Context, commitish string) error
CheckoutBranch(ctx context.Context, branch string) error
SnapshotHead(ctx context.Context) (*git.HeadSnapshot, error)
RestoreHead(ctx context.Context, snap *git.HeadSnapshot) error
}

// SubmoduleApplier switches tracked submodules to the branches
// recorded for a parent branch.
type SubmoduleApplier interface {
ApplyAssociations(ctx context.Context, parentBranch string) error
}

// GitRepository provides access to the Git repository methods
Expand All @@ -63,13 +71,14 @@ type Service interface {

// Handler provides a central place for handling checkout operations.
type Handler struct {
Stdout io.Writer // required
Log *silog.Logger // required
Store Store // required
Repository GitRepository // required
Worktree GitWorktree // required
Track TrackHandler // required
Service Service // required
Stdout io.Writer // required
Log *silog.Logger // required
Store Store // required
Repository GitRepository // required
Worktree GitWorktree // required
Track TrackHandler // required
Service Service // required
Submodule SubmoduleApplier // optional; nil disables submodule auto-checkout
}

// Request is a request to checkout a branch.
Expand Down Expand Up @@ -174,10 +183,37 @@ func (h *Handler) CheckoutBranch(ctx context.Context, req *Request) error {
return nil
}

// Snapshot parent HEAD before any state-changing op so we can
// roll back if the submodule apply fails downstream.
var parentSnap *git.HeadSnapshot
if h.Submodule != nil {
var err error
parentSnap, err = h.Worktree.SnapshotHead(ctx)
if err != nil {
// Snapshot failure is non-fatal — proceed without rollback.
log.Warn("Could not snapshot HEAD before checkout; "+
"submodule rollback disabled",
"error", err)
}
}

if err := h.Worktree.CheckoutBranch(ctx, branch); err != nil {
return fmt.Errorf("checkout branch: %w", err)
}

if h.Submodule != nil {
if err := h.Submodule.ApplyAssociations(ctx, branch); err != nil {
if parentSnap != nil {
if rerr := h.Worktree.RestoreHead(ctx, parentSnap); rerr != nil {
log.Warn("Parent rollback failed after submodule conflict",
"target", parentSnap.Hash,
"error", rerr)
}
}
return fmt.Errorf("apply submodules: %w", err)
}
}

if opts.Verbose {
log.Infof("switched to branch: %s", branch)
}
Expand Down
Loading
Loading