From dca7e44cbfff9803150bcdf74676d790844547e8 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Tue, 23 Jun 2026 10:41:07 -0400 Subject: [PATCH 1/4] restack: add Options parameter to RestackBranch/Stack/Downstack Introduces restack.Options{AutoResolve} and threads an opts *Options parameter through the high-level restack entry points (RestackBranch, RestackStack, RestackDownstack) and the merge/sync RestackHandler interfaces, with all current callers passing nil. This is foundational plumbing for the restack auto-resolve feature. Landing it on a branch below the consuming stacks lets every branch inherit the 3-arg signature, rather than the signature change living on a branch parallel to its callers. --- branch_restack.go | 2 +- downstack_restack.go | 2 +- internal/handler/merge/handler.go | 5 ++-- internal/handler/merge/handler_test.go | 6 ++--- internal/handler/merge/mocks_test.go | 13 ++++++----- internal/handler/restack/branch.go | 5 +++- internal/handler/restack/branch_test.go | 10 ++++---- internal/handler/restack/downstack.go | 5 +++- internal/handler/restack/downstack_test.go | 4 ++-- internal/handler/restack/handler.go | 27 ++++++++++++++++++++++ internal/handler/restack/stack.go | 5 +++- internal/handler/sync/handler.go | 4 ++-- internal/handler/sync/handler_test.go | 2 +- internal/handler/sync/mocks_test.go | 12 +++++----- stack_restack.go | 2 +- upstack_restack.go | 6 ++--- 16 files changed, 74 insertions(+), 36 deletions(-) diff --git a/branch_restack.go b/branch_restack.go index 32b8dc590..f84b803b3 100644 --- a/branch_restack.go +++ b/branch_restack.go @@ -32,5 +32,5 @@ 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, cmd.Branch) + return handler.RestackBranch(ctx, cmd.Branch, nil) } diff --git a/downstack_restack.go b/downstack_restack.go index e3f95a420..87a273f65 100644 --- a/downstack_restack.go +++ b/downstack_restack.go @@ -44,5 +44,5 @@ func (cmd *downstackRestackCmd) Run( return errors.New("nothing to restack below trunk") } - return handler.RestackDownstack(ctx, cmd.Branch) + return handler.RestackDownstack(ctx, cmd.Branch, nil) } diff --git a/internal/handler/merge/handler.go b/internal/handler/merge/handler.go index 8172b2334..0f921e882 100644 --- a/internal/handler/merge/handler.go +++ b/internal/handler/merge/handler.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "go.abhg.dev/gs/internal/handler/restack" "go.abhg.dev/gs/internal/handler/submit" "go.abhg.dev/gs/internal/handler/sync" @@ -36,7 +37,7 @@ type Service interface { // RestackHandler restacks branches after their bases are merged. type RestackHandler interface { - RestackBranch(context.Context, string) error + RestackBranch(ctx context.Context, branch string, opts *restack.Options) error } // SubmitHandler updates change requests after branch restacks. @@ -599,7 +600,7 @@ func (e *mergePlanExecutor) prepareForMerge( return fmt.Errorf("verify restacked: %w", err) } - if err := e.Restack.RestackBranch(ctx, item.branch); err != nil { + if err := e.Restack.RestackBranch(ctx, item.branch, nil); err != nil { return fmt.Errorf("restack branch: %w", err) } diff --git a/internal/handler/merge/handler_test.go b/internal/handler/merge/handler_test.go index 317d2a05b..f1d1f165f 100644 --- a/internal/handler/merge/handler_test.go +++ b/internal/handler/merge/handler_test.go @@ -317,10 +317,10 @@ func TestExecutePlan_retargets(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "feat2"). + RestackBranch(gomock.Any(), "feat2", gomock.Nil()). Return(nil) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "feat3"). + RestackBranch(gomock.Any(), "feat3", gomock.Nil()). Return(nil) mockSubmit := NewMockSubmitHandler(ctrl) @@ -396,7 +396,7 @@ func TestExecutePlan_waitsForPreparedChangeHeadBeforeChecks(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "feat2"). + RestackBranch(gomock.Any(), "feat2", gomock.Nil()). Return(nil) mockSubmit := NewMockSubmitHandler(ctrl) diff --git a/internal/handler/merge/mocks_test.go b/internal/handler/merge/mocks_test.go index 9d4523a57..c59410e48 100644 --- a/internal/handler/merge/mocks_test.go +++ b/internal/handler/merge/mocks_test.go @@ -13,6 +13,7 @@ import ( reflect "reflect" git "go.abhg.dev/gs/internal/git" + restack "go.abhg.dev/gs/internal/handler/restack" submit "go.abhg.dev/gs/internal/handler/submit" sync "go.abhg.dev/gs/internal/handler/sync" spice "go.abhg.dev/gs/internal/spice" @@ -207,17 +208,17 @@ func (m *MockRestackHandler) EXPECT() *MockRestackHandlerMockRecorder { } // RestackBranch mocks base method. -func (m *MockRestackHandler) RestackBranch(arg0 context.Context, arg1 string) error { +func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string, opts *restack.Options) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RestackBranch", arg0, arg1) + ret := m.ctrl.Call(m, "RestackBranch", ctx, branch, opts) ret0, _ := ret[0].(error) return ret0 } // RestackBranch indicates an expected call of RestackBranch. -func (mr *MockRestackHandlerMockRecorder) RestackBranch(arg0, arg1 any) *MockRestackHandlerRestackBranchCall { +func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch, opts any) *MockRestackHandlerRestackBranchCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), arg0, arg1) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch, opts) return &MockRestackHandlerRestackBranchCall{Call: call} } @@ -233,13 +234,13 @@ func (c *MockRestackHandlerRestackBranchCall) Return(arg0 error) *MockRestackHan } // Do rewrite *gomock.Call.Do -func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/handler/restack/branch.go b/internal/handler/restack/branch.go index 8d880183f..889d9a272 100644 --- a/internal/handler/restack/branch.go +++ b/internal/handler/restack/branch.go @@ -3,10 +3,13 @@ package restack import "context" // RestackBranch restacks the given branch onto its base. -func (h *Handler) RestackBranch(ctx context.Context, branch string) error { +func (h *Handler) RestackBranch( + ctx context.Context, branch string, opts *Options, +) error { _, err := h.Restack(ctx, &Request{ Branch: branch, ContinueCommand: []string{"branch", "restack"}, + AutoResolve: opts.autoResolvePtr(), }) return err } diff --git a/internal/handler/restack/branch_test.go b/internal/handler/restack/branch_test.go index 95664e688..c7435831c 100644 --- a/internal/handler/restack/branch_test.go +++ b/internal/handler/restack/branch_test.go @@ -47,7 +47,7 @@ func TestHandler_RestackBranch(t *testing.T) { Store: statetest.NewMemoryStore(t, "main", "", log), Service: mockService, } - require.NoError(t, handler.RestackBranch(t.Context(), "feature")) + require.NoError(t, handler.RestackBranch(t.Context(), "feature", nil)) assert.Contains(t, logBuffer.String(), "feature: restacked on main") }) @@ -82,7 +82,7 @@ func TestHandler_RestackBranch(t *testing.T) { Service: mockService, } - require.NoError(t, handler.RestackBranch(t.Context(), "feature")) + require.NoError(t, handler.RestackBranch(t.Context(), "feature", nil)) }) t.Run("UntrackedBranch", func(t *testing.T) { @@ -113,7 +113,7 @@ func TestHandler_RestackBranch(t *testing.T) { Service: mockService, } - err := handler.RestackBranch(t.Context(), "untracked") + err := handler.RestackBranch(t.Context(), "untracked", nil) require.Error(t, err) assert.ErrorContains(t, err, "untracked branch") assert.Contains(t, logBuffer.String(), "untracked: branch not tracked: run '"+cli.Name()+" branch track") @@ -146,7 +146,7 @@ func TestHandler_RestackBranch(t *testing.T) { Store: statetest.NewMemoryStore(t, "main", "", log), Service: mockService, } - require.NoError(t, handler.RestackBranch(t.Context(), "already-restacked")) + require.NoError(t, handler.RestackBranch(t.Context(), "already-restacked", nil)) assert.Contains(t, logBuffer.String(), "already-restacked: branch does not need to be restacked.") }) @@ -177,7 +177,7 @@ func TestHandler_RestackBranch(t *testing.T) { Store: statetest.NewMemoryStore(t, "main", "", log), Service: mockService, } - err := handler.RestackBranch(t.Context(), "feature") + err := handler.RestackBranch(t.Context(), "feature", nil) require.Error(t, err) assert.ErrorContains(t, err, "restack branch") assert.ErrorIs(t, err, unexpectedErr) diff --git a/internal/handler/restack/downstack.go b/internal/handler/restack/downstack.go index e4df6f7ab..60c0ab42d 100644 --- a/internal/handler/restack/downstack.go +++ b/internal/handler/restack/downstack.go @@ -4,11 +4,14 @@ import "context" // RestackDownstack restacks the downstack of the given branch. // This includes the branch itself. -func (h *Handler) RestackDownstack(ctx context.Context, branch string) error { +func (h *Handler) RestackDownstack( + ctx context.Context, branch string, opts *Options, +) error { _, err := h.Restack(ctx, &Request{ Branch: branch, Scope: ScopeDownstack, ContinueCommand: []string{"downstack", "restack"}, + AutoResolve: opts.autoResolvePtr(), }) return err } diff --git a/internal/handler/restack/downstack_test.go b/internal/handler/restack/downstack_test.go index fa633e067..a99ac3399 100644 --- a/internal/handler/restack/downstack_test.go +++ b/internal/handler/restack/downstack_test.go @@ -54,7 +54,7 @@ func TestHandler_RestackDownstack(t *testing.T) { Service: mockService, } - require.NoError(t, handler.RestackDownstack(t.Context(), "feature3")) + require.NoError(t, handler.RestackDownstack(t.Context(), "feature3", nil)) assert.Contains(t, logBuffer.String(), "feature1: restacked on main") assert.Contains(t, logBuffer.String(), "feature2: restacked on feature1") assert.Contains(t, logBuffer.String(), "feature3: restacked on feature2") @@ -99,6 +99,6 @@ func TestHandler_RestackDownstack(t *testing.T) { Service: mockService, } - require.NoError(t, handler.RestackDownstack(t.Context(), "feature2")) + require.NoError(t, handler.RestackDownstack(t.Context(), "feature2", nil)) }) } diff --git a/internal/handler/restack/handler.go b/internal/handler/restack/handler.go index 7ff1f6d45..93d0dadc1 100644 --- a/internal/handler/restack/handler.go +++ b/internal/handler/restack/handler.go @@ -93,6 +93,33 @@ type Request struct { // // Defaults to ScopeBranch. Scope Scope + + // AutoResolve, if non-nil, overrides + // [Handler.DefaultAutoResolve] for this invocation. A true + // value enables the configured resolver; a false value disables + // it even when configured. + AutoResolve *bool +} + +// Options are optional parameters shared by the high-level restack +// entry points ([Handler.RestackBranch], [Handler.RestackStack], +// [Handler.RestackDownstack]). +type Options struct { + // AutoResolve, if non-nil, overrides + // [Handler.DefaultAutoResolve] for this invocation. A true + // value enables the configured resolver; a false value disables + // it even when configured. + AutoResolve *bool +} + +// autoResolvePtr returns the AutoResolve pointer if opts is +// non-nil, or nil. Callers pass the result through to +// [Request.AutoResolve]. +func (o *Options) autoResolvePtr() *bool { + if o == nil { + return nil + } + return o.AutoResolve } // Restack restacks one or more branches according to the request. diff --git a/internal/handler/restack/stack.go b/internal/handler/restack/stack.go index f37ad358b..592e99eab 100644 --- a/internal/handler/restack/stack.go +++ b/internal/handler/restack/stack.go @@ -5,11 +5,14 @@ import "context" // RestackStack restacks the stack of the given branch. // This includes all upstack and downtrack branches, // as well as the branch itself. -func (h *Handler) RestackStack(ctx context.Context, branch string) error { +func (h *Handler) RestackStack( + ctx context.Context, branch string, opts *Options, +) error { _, err := h.Restack(ctx, &Request{ Branch: branch, Scope: ScopeStack, ContinueCommand: []string{"stack", "restack"}, + AutoResolve: opts.autoResolvePtr(), }) return err } diff --git a/internal/handler/sync/handler.go b/internal/handler/sync/handler.go index 4aa7da792..af116412d 100644 --- a/internal/handler/sync/handler.go +++ b/internal/handler/sync/handler.go @@ -78,7 +78,7 @@ type DeleteHandler interface { // RestackHandler allows restacking branches after sync // has removed their downstack branches. type RestackHandler interface { - RestackBranch(ctx context.Context, branch string) error + RestackBranch(ctx context.Context, branch string, opts *restack.Options) error RestackUpstack(ctx context.Context, branch string, opts *restack.UpstackOptions) error } @@ -983,7 +983,7 @@ func (h *Handler) restackDeletedBranchUpstack( return fmt.Errorf("restack upstack %q: %w", target, err) } case mode.Includes(spice.RestackAboves): - if err := h.Restack.RestackBranch(ctx, target); err != nil { + if err := h.Restack.RestackBranch(ctx, target, nil); err != nil { return fmt.Errorf("restack branch %q: %w", target, err) } case mode.Includes(spice.RestackNone): diff --git a/internal/handler/sync/handler_test.go b/internal/handler/sync/handler_test.go index b6450f281..415897d24 100644 --- a/internal/handler/sync/handler_test.go +++ b/internal/handler/sync/handler_test.go @@ -368,7 +368,7 @@ func TestHandler_SyncTrunk_restackDeletedUpstacks(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "child"). + RestackBranch(gomock.Any(), "child", gomock.Nil()). Return(nil) mockAutostash := NewMockAutostashHandler(ctrl) diff --git a/internal/handler/sync/mocks_test.go b/internal/handler/sync/mocks_test.go index 51c7adcf5..de0af2909 100644 --- a/internal/handler/sync/mocks_test.go +++ b/internal/handler/sync/mocks_test.go @@ -782,17 +782,17 @@ func (m *MockRestackHandler) EXPECT() *MockRestackHandlerMockRecorder { } // RestackBranch mocks base method. -func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string) error { +func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string, opts *restack.Options) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RestackBranch", ctx, branch) + ret := m.ctrl.Call(m, "RestackBranch", ctx, branch, opts) ret0, _ := ret[0].(error) return ret0 } // RestackBranch indicates an expected call of RestackBranch. -func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch any) *MockRestackHandlerRestackBranchCall { +func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch, opts any) *MockRestackHandlerRestackBranchCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch, opts) return &MockRestackHandlerRestackBranchCall{Call: call} } @@ -808,13 +808,13 @@ func (c *MockRestackHandlerRestackBranchCall) Return(arg0 error) *MockRestackHan } // Do rewrite *gomock.Call.Do -func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/stack_restack.go b/stack_restack.go index 88ef562be..7d7b4d689 100644 --- a/stack_restack.go +++ b/stack_restack.go @@ -88,5 +88,5 @@ func (cmd *stackRestackCmd) Run( return err } - return handler.RestackStack(ctx, cmd.Branch) + return handler.RestackStack(ctx, cmd.Branch, nil) } diff --git a/upstack_restack.go b/upstack_restack.go index f6e6e0520..4def1ada7 100644 --- a/upstack_restack.go +++ b/upstack_restack.go @@ -34,10 +34,10 @@ func (*upstackRestackCmd) Help() string { // RestackHandler implements high level restack operations. type RestackHandler interface { RestackUpstack(ctx context.Context, branch string, opts *restack.UpstackOptions) error - RestackDownstack(ctx context.Context, branch string) error + RestackDownstack(ctx context.Context, branch string, opts *restack.Options) error Restack(context.Context, *restack.Request) (int, error) - RestackStack(ctx context.Context, branch string) error - RestackBranch(ctx context.Context, branch string) error + RestackStack(ctx context.Context, branch string, opts *restack.Options) error + RestackBranch(ctx context.Context, branch string, opts *restack.Options) error } func (cmd *upstackRestackCmd) AfterApply(ctx context.Context, wt *git.Worktree) error { From 57e95072a0209acefbcbb128d1eaa7f1b8d04225 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Tue, 2 Jun 2026 18:30:52 -0400 Subject: [PATCH 2/4] submodule: add tracking commands and fix absorbed-gitdir worktree resolution --- .../unreleased/Added-20260313-074533.yaml | 3 + branch.go | 3 + branch_create.go | 13 ++ branch_submodule.go | 20 ++ branch_submodule_list.go | 57 +++++ branch_submodule_repoint.go | 70 ++++++ commit_amend.go | 10 +- commit_create.go | 8 + doc/includes/cli-reference.md | 130 ++++------- doc/includes/cli-shorthands.md | 3 +- internal/git/branch.go | 38 +++- internal/git/branch_test.go | 70 ++++++ internal/git/submodule.go | 136 +++++++++++ internal/git/submodule_test.go | 140 ++++++++++++ internal/handler/list/handler.go | 5 + internal/handler/submodule/tracker.go | 135 +++++++++++ internal/handler/submodule/tracker_test.go | 214 ++++++++++++++++++ internal/spice/branch.go | 10 + internal/spice/config.go | 10 + internal/spice/state/branch.go | 51 +++++ internal/spice/state/branch_int_test.go | 21 ++ internal/spice/state/branch_test.go | 123 ++++++++++ internal/spice/state/store_test.go | 25 ++ internal/ui/branchtree/tree.go | 29 +++ log.go | 8 + main.go | 20 +- testdata/help/branch_submodule_list.txt | 17 ++ testdata/help/branch_submodule_repoint.txt | 21 ++ testdata/help/gs.txt | 7 +- testdata/script/submodule_list.txt | 63 ++++++ testdata/script/submodule_log_display.txt | 47 ++++ testdata/script/submodule_repoint.txt | 70 ++++++ .../script/submodule_restack_worktree.txt | 60 +++++ testdata/script/submodule_track_commit.txt | 60 +++++ 34 files changed, 1607 insertions(+), 90 deletions(-) create mode 100644 .changes/unreleased/Added-20260313-074533.yaml create mode 100644 branch_submodule.go create mode 100644 branch_submodule_list.go create mode 100644 branch_submodule_repoint.go create mode 100644 internal/git/submodule.go create mode 100644 internal/git/submodule_test.go create mode 100644 internal/handler/submodule/tracker.go create mode 100644 internal/handler/submodule/tracker_test.go create mode 100644 testdata/help/branch_submodule_list.txt create mode 100644 testdata/help/branch_submodule_repoint.txt create mode 100644 testdata/script/submodule_list.txt create mode 100644 testdata/script/submodule_log_display.txt create mode 100644 testdata/script/submodule_repoint.txt create mode 100644 testdata/script/submodule_restack_worktree.txt create mode 100644 testdata/script/submodule_track_commit.txt diff --git a/.changes/unreleased/Added-20260313-074533.yaml b/.changes/unreleased/Added-20260313-074533.yaml new file mode 100644 index 000000000..fdb873c44 --- /dev/null +++ b/.changes/unreleased/Added-20260313-074533.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'submodule: Add `gs branch submodule list` and `gs branch submodule repoint` commands. Submodule branch associations are recorded automatically at commit time and displayed as `[path:branch]` in `gs ls` and `gs ll`.' +time: 2026-03-13T07:45:33.612939-04:00 diff --git a/branch.go b/branch.go index 160587832..b90848e64 100644 --- a/branch.go +++ b/branch.go @@ -38,6 +38,9 @@ type branchCmd struct { // Pull request management Merge branchMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch into trunk"` Submit branchSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch"` + + // Submodule management + Submodule branchSubmoduleCmd `cmd:"" aliases:"sm" help:"Manage submodule branch associations"` } // BranchPromptConfig defines configuration for the branch tree prompt diff --git a/branch_create.go b/branch_create.go index 1238f6479..72911bb2f 100644 --- a/branch_create.go +++ b/branch_create.go @@ -104,6 +104,7 @@ func (cmd *branchCreateCmd) Run( wt *git.Worktree, store *state.Store, svc *spice.Service, + submoduleTracker SubmoduleTracker, restackHandler RestackHandler, ) (err error) { // If a message is specified, automatically enable commits @@ -323,6 +324,18 @@ func (cmd *branchCreateCmd) Run( return fmt.Errorf("update branch state: %w", err) } + // Record submodule associations if a commit was made. + if cmd.Commit { + if err := submoduleTracker.RecordBranchState( + ctx, branchName, + ); err != nil { + log.Warn( + "Could not record submodule associations", + "error", err, + ) + } + } + if cmd.Below || cmd.Insert { return restackHandler.RestackUpstack(ctx, branchName, nil) } diff --git a/branch_submodule.go b/branch_submodule.go new file mode 100644 index 000000000..f7e5613a4 --- /dev/null +++ b/branch_submodule.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + + "go.abhg.dev/gs/internal/handler/submodule" +) + +// SubmoduleTracker records submodule branch associations +// for the current worktree state. +type SubmoduleTracker interface { + RecordBranchState(ctx context.Context, branch string) error +} + +var _ SubmoduleTracker = (*submodule.Tracker)(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"` +} diff --git a/branch_submodule_list.go b/branch_submodule_list.go new file mode 100644 index 000000000..9c4db7e3f --- /dev/null +++ b/branch_submodule_list.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + "maps" + "slices" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchSubmoduleListCmd struct { + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to list associations for. Defaults to current branch."` +} + +func (*branchSubmoduleListCmd) Help() string { + return text.Dedent(` + Shows the submodule branch associations + recorded for the given branch. + If no branch is specified, + the current branch is used. + `) +} + +func (cmd *branchSubmoduleListCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + resp, err := store.LookupBranch(ctx, branch) + if err != nil { + return fmt.Errorf("lookup branch %v: %w", branch, err) + } + + if len(resp.Submodules) == 0 { + log.Infof("%v: no submodule associations", branch) + return nil + } + + for _, path := range slices.Sorted(maps.Keys(resp.Submodules)) { + log.Infof("%-30s \u2192 %s", path, resp.Submodules[path]) + } + return nil +} diff --git a/branch_submodule_repoint.go b/branch_submodule_repoint.go new file mode 100644 index 000000000..35e5bffce --- /dev/null +++ b/branch_submodule_repoint.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchSubmoduleRepointCmd struct { + Path string `arg:"" help:"Submodule path to repoint."` + Branch string `short:"b" placeholder:"BRANCH" help:"Submodule branch to associate. Defaults to submodule's current branch."` +} + +func (*branchSubmoduleRepointCmd) Help() string { + return text.Dedent(` + Changes which submodule branch is associated + with the current parent branch. + + If --branch is not specified, + the submodule's current branch is used. + `) +} + +func (cmd *branchSubmoduleRepointCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, +) error { + parentBranch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + + branch := cmd.Branch + if branch == "" { + branch, err = wt.SubmoduleCurrentBranch( + ctx, cmd.Path, + ) + if err != nil { + return fmt.Errorf( + "submodule %s current branch: %w", + cmd.Path, err, + ) + } + } + + tx := store.BeginBranchTx() + if err := tx.Upsert(ctx, state.UpsertRequest{ + Name: parentBranch, + Submodules: map[string]string{ + cmd.Path: branch, + }, + }); err != nil { + return fmt.Errorf("update submodule association: %w", err) + } + + if err := tx.Commit(ctx, + "repoint submodule "+cmd.Path, + ); err != nil { + return fmt.Errorf("commit state: %w", err) + } + + log.Infof("%v: %v \u2192 %v", parentBranch, cmd.Path, branch) + return nil +} diff --git a/commit_amend.go b/commit_amend.go index 6cfa4ff79..3b64eb73f 100644 --- a/commit_amend.go +++ b/commit_amend.go @@ -61,6 +61,7 @@ func (cmd *commitAmendCmd) Run( wt *git.Worktree, store *state.Store, svc *spice.Service, + submoduleTracker SubmoduleTracker, restackHandler RestackHandler, ) error { var detachedHead bool @@ -128,7 +129,7 @@ func (cmd *commitAmendCmd) Run( MessageFile: cmd.MessageFile, Signoff: cmd.Signoff, Commit: true, - }).Run(ctx, log, repo, wt, store, svc, restackHandler) + }).Run(ctx, log, repo, wt, store, svc, submoduleTracker, restackHandler) } } } @@ -241,6 +242,13 @@ func (cmd *commitAmendCmd) Run( return nil } + if err := submoduleTracker.RecordBranchState( + ctx, currentBranch, + ); err != nil { + log.Warn("Could not record submodule associations", + "error", err) + } + return restackHandler.RestackUpstack(ctx, currentBranch, &restack.UpstackOptions{ SkipStart: true, }) diff --git a/commit_create.go b/commit_create.go index 4ba090a64..d861c1ab7 100644 --- a/commit_create.go +++ b/commit_create.go @@ -48,6 +48,7 @@ func (cmd *commitCreateCmd) Run( ctx context.Context, log *silog.Logger, wt *git.Worktree, + submoduleTracker SubmoduleTracker, restackHandler RestackHandler, ) error { if err := wt.Commit(ctx, git.CommitRequest{ @@ -79,6 +80,13 @@ func (cmd *commitCreateCmd) Run( return fmt.Errorf("get current branch: %w", err) } + if err := submoduleTracker.RecordBranchState( + ctx, currentBranch, + ); err != nil { + log.Warn("Could not record submodule associations", + "error", err) + } + return restackHandler.RestackUpstack(ctx, currentBranch, &restack.UpstackOptions{ SkipStart: true, }) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..e560a65e0 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,50 +286,6 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) -### git-spice stack merge {#gs-stack-merge} - -``` -gs stack (s) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a stack - -Merges the CRs for the current branch's stack into trunk. -Use --branch to merge a different branch's stack. - -The stack includes the selected branch, -its downstack branches down to trunk, -and every upstack branch. - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -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 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. -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'. -* `--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.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) - ### git-spice stack restack {#gs-stack-restack} ``` @@ -626,7 +582,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 merge readiness on the updated Change Request, +waits for its CI checks to pass, and then repeats the process. For a stack like this: @@ -644,15 +600,13 @@ 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 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 each merge, waits for CI checks to pass. +Use --build-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 merge readiness on the updated PR, +waits for CI checks on the updated PR, and syncs merged branch cleanup. Use --no-wait for single branch merging @@ -661,13 +615,13 @@ 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'. -* `--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 start merging from * `--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 +* `--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. -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice downstack edit {#gs-downstack-edit} @@ -1121,35 +1075,6 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff -### git-spice branch merge {#gs-branch-merge} - -``` -gs branch (b) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch into trunk - -Merges the CR for the current branch into trunk. -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 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'. -* `--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.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) - ### git-spice branch submit {#gs-branch-submit} ``` @@ -1205,6 +1130,45 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice branch submodule list {#gs-branch-submodule-list} + +``` +gs branch (b) submodule (sm) list (ls) [flags] +``` + +List submodule branch associations + +Shows the submodule branch associations +recorded for the given branch. +If no branch is specified, +the current branch is used. + +**Flags** + +* `-b`, `--branch=BRANCH`: Branch to list associations for. Defaults to current branch. + +### git-spice branch submodule repoint {#gs-branch-submodule-repoint} + +``` +gs branch (b) submodule (sm) repoint [flags] +``` + +Change submodule branch association for the current branch + +Changes which submodule branch is associated +with the current parent branch. + +If --branch is not specified, +the submodule's current branch is used. + +**Arguments** + +* `path`: Submodule path to repoint. + +**Flags** + +* `-b`, `--branch=BRANCH`: Submodule branch to associate. Defaults to submodule's current branch. + ## Commit ### git-spice commit create {#gs-commit-create} diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 064cad722..26e157f3b 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -6,11 +6,11 @@ | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | -| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | | gs bs | [gs branch submit](/cli/reference.md#gs-branch-submit) | +| gs bsmls | [gs branch submodule list](/cli/reference.md#gs-branch-submodule-list) | | gs bsp | [gs branch split](/cli/reference.md#gs-branch-split) | | gs bsq | [gs branch squash](/cli/reference.md#gs-branch-squash) | | gs btr | [gs branch track](/cli/reference.md#gs-branch-track) | @@ -34,7 +34,6 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | -| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | diff --git a/internal/git/branch.go b/internal/git/branch.go index 8d7ea68e0..d380db36d 100644 --- a/internal/git/branch.go +++ b/internal/git/branch.go @@ -59,6 +59,37 @@ func (r *Repository) LocalBranches(ctx context.Context, opts *LocalBranchesOptio args = append(args, "refs/heads/") } + // Lazily resolve the main worktree path + // for submodules with absorbed gitdirs. + // Git reports %(worktreepath) as the gitdir + // for the main worktree of a submodule + // instead of the actual working directory. + var mainWorktreeResolved bool + var mainWorktreePath string + resolveWorktree := func(wt string) string { + if wt != r.gitDir { + return wt + } + + // %(worktreepath) matches our gitDir, + // which happens for submodules + // with absorbed gitdirs. + // Resolve the actual worktree path. + if !mainWorktreeResolved { + mainWorktreeResolved = true + if resolved, err := r.gitCmd(ctx, + "rev-parse", "--show-toplevel", + ).OutputChomp(); err == nil { + mainWorktreePath = resolved + } + } + + if mainWorktreePath != "" { + return mainWorktreePath + } + return wt + } + return func(yield func(LocalBranch, error) bool) { cmd := r.gitCmd(ctx, "for-each-ref", args...) for bs, err := range cmd.Lines() { @@ -83,10 +114,15 @@ func (r *Repository) LocalBranches(ctx context.Context, opts *LocalBranchesOptio continue } + wtPath := string(bytes.TrimSpace(worktree)) + if wtPath != "" { + wtPath = resolveWorktree(wtPath) + } + localBranch := LocalBranch{ Name: string(branchName), Hash: Hash(hash), - Worktree: string(bytes.TrimSpace(worktree)), + Worktree: wtPath, } if !yield(localBranch, nil) { return diff --git a/internal/git/branch_test.go b/internal/git/branch_test.go index 798e04b08..bb397d598 100644 --- a/internal/git/branch_test.go +++ b/internal/git/branch_test.go @@ -335,6 +335,76 @@ func TestIntegrationLocalBranchesWorktrees(t *testing.T) { }, bs) } +// Verifies that LocalBranches returns the correct worktree path +// for branches inside a submodule with an absorbed gitdir. +// +// Git reports %(worktreepath) as the gitdir path +// for the main worktree of a submodule, +// rather than the actual working tree directory. +// LocalBranches must resolve this to the real worktree path. +func TestIntegrationLocalBranchesSubmodule(t *testing.T) { + t.Parallel() + ctx := t.Context() + log := silogtest.New(t) + + // Resolve symlinks for macOS /var -> /private/var. + evalDir := func(t *testing.T, dir string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + return resolved + } + + // Set up a child repo that will be added as a submodule. + childDir := evalDir(t, t.TempDir()) + initGitRepo(t, childDir) + runGit(t, childDir, "checkout", "-b", "feature") + runGit(t, childDir, "commit", "--allow-empty", "-m", "feature") + + // Set up a parent repo with the child as a submodule. + parentDir := evalDir(t, t.TempDir()) + initGitRepo(t, parentDir) + addSubmodule(t, parentDir, childDir, "child") + runGit(t, parentDir, "commit", "-m", "add submodule") + + // Open the submodule's worktree via the parent. + subDir := filepath.Join(parentDir, "child") + subWt, err := git.OpenWorktree(ctx, subDir, git.OpenOptions{ + Log: log, + }) + require.NoError(t, err) + + // Verify the worktree root is the submodule directory, + // not the absorbed gitdir. + assert.Equal(t, joinSlash(subDir), joinSlash(subWt.RootDir())) + + subRepo := subWt.Repository() + bs, err := sliceutil.CollectErr( + subRepo.LocalBranches(ctx, nil), + ) + require.NoError(t, err) + + // The checked-out branch ("feature") must have + // the submodule's working directory as its worktree path, + // not the absorbed gitdir + // (e.g., parent/.git/modules/child). + var featureBranch git.LocalBranch + for _, b := range bs { + if b.Name == "feature" { + featureBranch = b + break + } + } + require.NotEmpty(t, featureBranch.Name, + "feature branch not found") + assert.Equal(t, + joinSlash(subDir), + featureBranch.Worktree, + "worktree path should be the submodule directory, "+ + "not the absorbed gitdir", + ) +} + func TestIntegrationRemoteBranches(t *testing.T) { t.Parallel() diff --git a/internal/git/submodule.go b/internal/git/submodule.go new file mode 100644 index 000000000..860758066 --- /dev/null +++ b/internal/git/submodule.go @@ -0,0 +1,136 @@ +package git + +import ( + "bufio" + "context" + "fmt" + "path/filepath" + "strings" +) + +// Submodule describes a git submodule +// registered in a repository's .gitmodules. +type Submodule struct { + // Path is the relative path from the repo root. + Path string + + // URL is the configured remote URL. + URL string +} + +// Submodules lists all submodules in the worktree. +// Returns an empty slice if no submodules are configured. +func (w *Worktree) Submodules( + ctx context.Context, +) ([]Submodule, error) { + out, err := w.gitCmd(ctx, + "config", "--file", ".gitmodules", + "--get-regexp", `^submodule\..*\.path$`, + ).OutputChomp() + if err != nil { + // No .gitmodules or no submodules configured. + return nil, nil //nolint:nilerr + } + + return parseSubmoduleConfig(ctx, w, out) +} + +// parseSubmoduleConfig parses `git config --get-regexp` output +// for submodule paths, then looks up each submodule's URL. +func parseSubmoduleConfig( + ctx context.Context, + w *Worktree, + output string, +) ([]Submodule, error) { + var subs []Submodule + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + // Format: submodule..path + key, path, ok := strings.Cut(line, " ") + if !ok { + continue + } + + sub, err := submoduleFromConfigKey(ctx, w, key, path) + if err != nil { + return nil, err + } + subs = append(subs, sub) + } + return subs, nil +} + +// submoduleFromConfigKey resolves a single submodule entry +// from its config key (submodule..path) and path value. +func submoduleFromConfigKey( + ctx context.Context, + w *Worktree, + key, path string, +) (Submodule, error) { + // Extract name: submodule..path → + name := strings.TrimPrefix(key, "submodule.") + name = strings.TrimSuffix(name, ".path") + + urlKey := "submodule." + name + ".url" + url, err := w.gitCmd(ctx, + "config", "--file", ".gitmodules", + "--get", urlKey, + ).OutputChomp() + if err != nil { + url = "" // URL may be missing for local submodules. + } + + return Submodule{ + Path: strings.TrimSpace(path), + URL: strings.TrimSpace(url), + }, nil +} + +// SubmoduleCurrentBranch reports the current branch +// of a submodule at the given relative path. +// Returns [ErrDetachedHead] if the submodule HEAD is detached. +func (w *Worktree) SubmoduleCurrentBranch( + ctx context.Context, path string, +) (string, error) { + absPath := filepath.Join(w.rootDir, path) + name, err := newGitCmd(ctx, w.log, w.exec, + "branch", "--show-current", + ).WithDir(absPath).OutputChomp() + if err != nil { + return "", fmt.Errorf("submodule %s: %w", path, err) + } + name = strings.TrimSpace(name) + if name == "" { + return "", ErrDetachedHead + } + return name, nil +} + +// SubmoduleWorktree opens a [Worktree] for the submodule +// at the given relative path. +func (w *Worktree) SubmoduleWorktree( + ctx context.Context, path string, +) (*Worktree, error) { + absPath := filepath.Join(w.rootDir, path) + return OpenWorktree(ctx, absPath, OpenOptions{ + Log: w.log, + exec: w.exec, + }) +} + +// UpdateSubmodulePointer stages an updated commit hash +// for a submodule in the index. +// The caller must commit the change separately. +func (w *Worktree) UpdateSubmodulePointer( + ctx context.Context, path string, hash Hash, +) error { + // 160000 is the git file mode for submodule links. + entry := fmt.Sprintf("160000,%s,%s", hash, path) + if err := w.gitCmd(ctx, + "update-index", "--cacheinfo", entry, + ).Run(); err != nil { + return fmt.Errorf("update submodule %s: %w", path, err) + } + return nil +} diff --git a/internal/git/submodule_test.go b/internal/git/submodule_test.go new file mode 100644 index 000000000..53695f92d --- /dev/null +++ b/internal/git/submodule_test.go @@ -0,0 +1,140 @@ +package git_test + +import ( + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog/silogtest" +) + +func TestSubmodules(t *testing.T) { + t.Parallel() + ctx := t.Context() + log := silogtest.New(t) + + // Set up a submodule repo. + subDir := t.TempDir() + initGitRepo(t, subDir) + + // Set up a parent repo with the submodule. + parentDir := t.TempDir() + initGitRepo(t, parentDir) + addSubmodule(t, parentDir, subDir, "libs/core") + runGit(t, parentDir, "commit", "-m", "add submodule") + + parentWt, err := git.OpenWorktree(ctx, parentDir, git.OpenOptions{ + Log: log, + }) + require.NoError(t, err) + + t.Run("ListSubmodules", func(t *testing.T) { + subs, err := parentWt.Submodules(ctx) + require.NoError(t, err) + require.Len(t, subs, 1) + assert.Equal(t, "libs/core", subs[0].Path) + assert.Equal(t, subDir, subs[0].URL) + }) + + t.Run("SubmoduleCurrentBranch", func(t *testing.T) { + branch, err := parentWt.SubmoduleCurrentBranch( + ctx, "libs/core", + ) + require.NoError(t, err) + assert.Equal(t, "main", branch) + }) + + t.Run("SubmoduleWorktree", func(t *testing.T) { + subWt, err := parentWt.SubmoduleWorktree(ctx, "libs/core") + require.NoError(t, err) + // Normalize separators: RootDir comes from + // 'git rev-parse --show-toplevel' which uses forward + // slashes on all platforms, while filepath.Join uses + // the platform separator. + assert.Equal(t, + filepath.ToSlash( + filepath.Join(parentWt.RootDir(), "libs", "core"), + ), + filepath.ToSlash(subWt.RootDir()), + ) + }) +} + +func TestSubmodules_noSubmodules(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + ctx := t.Context() + wt, err := git.OpenWorktree(ctx, dir, git.OpenOptions{ + Log: silogtest.New(t), + }) + require.NoError(t, err) + + subs, err := wt.Submodules(ctx) + require.NoError(t, err) + assert.Empty(t, subs) +} + +func TestSubmoduleCurrentBranch_detachedHead(t *testing.T) { + t.Parallel() + ctx := t.Context() + log := silogtest.New(t) + + subDir := t.TempDir() + initGitRepo(t, subDir) + + parentDir := t.TempDir() + initGitRepo(t, parentDir) + addSubmodule(t, parentDir, subDir, "libs/core") + runGit(t, parentDir, "commit", "-m", "add submodule") + + // Detach submodule HEAD. + runGit(t, filepath.Join(parentDir, "libs", "core"), + "checkout", "--detach", "HEAD", + ) + + parentWt, err := git.OpenWorktree(ctx, parentDir, git.OpenOptions{ + Log: log, + }) + require.NoError(t, err) + + _, err = parentWt.SubmoduleCurrentBranch(ctx, "libs/core") + assert.ErrorIs(t, err, git.ErrDetachedHead) +} + +// initGitRepo creates a bare-minimum git repo with one commit. +func initGitRepo(t *testing.T, dir string) { + t.Helper() + + runGit(t, dir, "init", "--initial-branch=main") + runGit(t, dir, "config", "user.name", "Test") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "commit", "--allow-empty", "-m", "init") +} + +// addSubmodule adds a local repo as a submodule, +// enabling the file:// transport protocol. +func addSubmodule(t *testing.T, parentDir, subDir, path string) { + t.Helper() + cmd := exec.Command("git", + "-c", "protocol.file.allow=always", + "submodule", "add", subDir, path, + ) + cmd.Dir = parentDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git submodule add: %s", out) +} + +// runGit runs a git command in the given directory. +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) +} diff --git a/internal/handler/list/handler.go b/internal/handler/list/handler.go index f71702562..d25c2d29a 100644 --- a/internal/handler/list/handler.go +++ b/internal/handler/list/handler.go @@ -150,6 +150,10 @@ type BranchItem struct { // NeedsRestack indicates whether this branch needs to be restacked // on top of its base branch. NeedsRestack bool + + // Submodules maps submodule paths + // to associated branch names. + Submodules map[string]string } // PushStatus contains push-related information @@ -278,6 +282,7 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran } item.Base = branch.Base + item.Submodules = branch.Submodules if branch.Change != nil { item.ChangeID = branch.Change.ChangeID() diff --git a/internal/handler/submodule/tracker.go b/internal/handler/submodule/tracker.go new file mode 100644 index 000000000..27ff2e228 --- /dev/null +++ b/internal/handler/submodule/tracker.go @@ -0,0 +1,135 @@ +// Package submodule provides submodule-aware operations +// for git-spice, including discovery, branch tracking, +// and recursive orchestration. +package submodule + +import ( + "context" + "errors" + "fmt" + "slices" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" +) + +// Tracker discovers submodules and resolves +// branch associations for the current parent branch. +type Tracker struct { + Log *silog.Logger + Worktree GitWorktree + Store *state.Store // used by RecordBranchState + Exclude []string // submodule paths to skip +} + +// RecordBranchState resolves submodule associations +// and records them in the store for the given branch. +func (t *Tracker) RecordBranchState( + ctx context.Context, branch string, +) error { + assocs, err := t.ResolveAssociations(ctx) + if err != nil { + return fmt.Errorf( + "resolve submodule associations: %w", err, + ) + } + return RecordAssociations(ctx, t.Store, branch, assocs) +} + +// GitWorktree is the subset of [git.Worktree] +// needed by the tracker. +type GitWorktree interface { + Submodules(ctx context.Context) ([]git.Submodule, error) + SubmoduleCurrentBranch(ctx context.Context, path string) (string, error) +} + +var _ GitWorktree = (*git.Worktree)(nil) + +// BranchAssociation maps a submodule path +// to the branch it is currently on. +type BranchAssociation struct { + Path string + Branch string +} + +// ResolveAssociations returns the current branch association +// for each gs-initialized, non-excluded submodule. +// Submodules in detached HEAD state are skipped. +func (t *Tracker) ResolveAssociations( + ctx context.Context, +) ([]BranchAssociation, error) { + subs, err := t.Worktree.Submodules(ctx) + if err != nil { + return nil, err + } + + var assocs []BranchAssociation + for _, sub := range subs { + if t.isExcluded(sub.Path) { + t.Log.Debug("Skipping excluded submodule", + "path", sub.Path) + continue + } + + assoc, err := t.resolveOne(ctx, sub.Path) + if err != nil { + return nil, err + } + if assoc != nil { + assocs = append(assocs, *assoc) + } + } + return assocs, nil +} + +func (t *Tracker) resolveOne( + ctx context.Context, path string, +) (*BranchAssociation, error) { + branch, err := t.Worktree.SubmoduleCurrentBranch( + ctx, path, + ) + if err != nil { + if errors.Is(err, git.ErrDetachedHead) { + t.Log.Debug("Submodule in detached HEAD", + "path", path) + return nil, nil + } + return nil, err + } + return &BranchAssociation{ + Path: path, + Branch: branch, + }, nil +} + +func (t *Tracker) isExcluded(path string) bool { + return slices.Contains(t.Exclude, path) +} + +// RecordAssociations updates a branch's submodule +// associations in the store based on current submodule state. +func RecordAssociations( + ctx context.Context, + store *state.Store, + branch string, + assocs []BranchAssociation, +) error { + if len(assocs) == 0 { + return nil + } + + subs := make(map[string]string, len(assocs)) + for _, a := range assocs { + subs[a.Path] = a.Branch + } + + tx := store.BeginBranchTx() + if err := tx.Upsert(ctx, state.UpsertRequest{ + Name: branch, + Submodules: subs, + }); err != nil { + return err + } + return tx.Commit(ctx, "record submodule associations") +} diff --git a/internal/handler/submodule/tracker_test.go b/internal/handler/submodule/tracker_test.go new file mode 100644 index 000000000..0a467250b --- /dev/null +++ b/internal/handler/submodule/tracker_test.go @@ -0,0 +1,214 @@ +package submodule_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/submodule" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/spice/state/storage" +) + +type fakeWorktree struct { + subs []git.Submodule + branches map[string]string // path → branch + detached map[string]bool // path → true if detached +} + +func (f *fakeWorktree) Submodules( + _ context.Context, +) ([]git.Submodule, error) { + return f.subs, nil +} + +func (f *fakeWorktree) SubmoduleCurrentBranch( + _ context.Context, path string, +) (string, error) { + if f.detached[path] { + return "", git.ErrDetachedHead + } + return f.branches[path], nil +} + +func TestTracker_ResolveAssociations(t *testing.T) { + t.Parallel() + + wt := &fakeWorktree{ + subs: []git.Submodule{ + {Path: "libs/core", URL: "https://example.com/core"}, + {Path: "libs/util", URL: "https://example.com/util"}, + {Path: "vendor/ext", URL: "https://example.com/ext"}, + }, + branches: map[string]string{ + "libs/core": "feat-core", + "libs/util": "feat-util", + "vendor/ext": "main", + }, + } + + tracker := submodule.Tracker{ + Log: silog.Nop(), + Worktree: wt, + } + + assocs, err := tracker.ResolveAssociations(t.Context()) + require.NoError(t, err) + assert.Equal(t, []submodule.BranchAssociation{ + {Path: "libs/core", Branch: "feat-core"}, + {Path: "libs/util", Branch: "feat-util"}, + {Path: "vendor/ext", Branch: "main"}, + }, assocs) +} + +func TestTracker_ResolveAssociations_excluded(t *testing.T) { + t.Parallel() + + wt := &fakeWorktree{ + subs: []git.Submodule{ + {Path: "libs/core"}, + {Path: "libs/excluded"}, + }, + branches: map[string]string{ + "libs/core": "feat-core", + "libs/excluded": "feat-excluded", + }, + } + + tracker := submodule.Tracker{ + Log: silog.Nop(), + Worktree: wt, + Exclude: []string{"libs/excluded"}, + } + + assocs, err := tracker.ResolveAssociations(t.Context()) + require.NoError(t, err) + assert.Equal(t, []submodule.BranchAssociation{ + {Path: "libs/core", Branch: "feat-core"}, + }, assocs) +} + +func TestTracker_ResolveAssociations_detachedSkipped( + t *testing.T, +) { + t.Parallel() + + wt := &fakeWorktree{ + subs: []git.Submodule{ + {Path: "libs/core"}, + {Path: "libs/detached"}, + }, + branches: map[string]string{ + "libs/core": "feat-core", + }, + detached: map[string]bool{ + "libs/detached": true, + }, + } + + tracker := submodule.Tracker{ + Log: silog.Nop(), + Worktree: wt, + } + + assocs, err := tracker.ResolveAssociations(t.Context()) + require.NoError(t, err) + assert.Equal(t, []submodule.BranchAssociation{ + {Path: "libs/core", Branch: "feat-core"}, + }, assocs) +} + +func TestTracker_ResolveAssociations_noSubmodules( + t *testing.T, +) { + t.Parallel() + + wt := &fakeWorktree{} + tracker := submodule.Tracker{ + Log: silog.Nop(), + Worktree: wt, + } + + assocs, err := tracker.ResolveAssociations(t.Context()) + require.NoError(t, err) + assert.Empty(t, assocs) +} + +func TestTracker_RecordBranchState(t *testing.T) { + t.Parallel() + + wt := &fakeWorktree{ + subs: []git.Submodule{ + {Path: "libs/core"}, + {Path: "libs/util"}, + }, + branches: map[string]string{ + "libs/core": "feat-core", + "libs/util": "feat-util", + }, + } + + ctx := t.Context() + db := storage.NewDB(make(storage.MapBackend)) + store, err := state.InitStore(ctx, state.InitStoreRequest{ + DB: db, + Trunk: "main", + }) + require.NoError(t, err) + + // Create the branch first so RecordBranchState can upsert. + tx := store.BeginBranchTx() + require.NoError(t, tx.Upsert(ctx, state.UpsertRequest{ + Name: "feature-x", + Base: "main", + })) + require.NoError(t, tx.Commit(ctx, "track branch")) + + tracker := submodule.Tracker{ + Log: silog.Nop(), + Worktree: wt, + Store: store, + } + + require.NoError(t, + tracker.RecordBranchState(ctx, "feature-x"), + ) + + resp, err := store.LookupBranch(ctx, "feature-x") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "libs/core": "feat-core", + "libs/util": "feat-util", + }, resp.Submodules) +} + +func TestTracker_RecordBranchState_noSubmodules( + t *testing.T, +) { + t.Parallel() + + ctx := t.Context() + db := storage.NewDB(make(storage.MapBackend)) + store, err := state.InitStore(ctx, state.InitStoreRequest{ + DB: db, + Trunk: "main", + }) + require.NoError(t, err) + + tracker := submodule.Tracker{ + Log: silog.Nop(), + Worktree: &fakeWorktree{}, + Store: store, + } + + // No submodules means no-op; should not error. + require.NoError(t, + tracker.RecordBranchState(ctx, "feature-x"), + ) +} + +// Ensure fakeWorktree implements the interface. +var _ submodule.GitWorktree = (*fakeWorktree)(nil) diff --git a/internal/spice/branch.go b/internal/spice/branch.go index ff66d4c85..d522c4917 100644 --- a/internal/spice/branch.go +++ b/internal/spice/branch.go @@ -82,6 +82,10 @@ type LookupBranchResponse struct { // // This is used to correctly display the history of the branch. MergedDownstack []json.RawMessage + + // Submodules maps submodule paths + // to associated branch names. + Submodules map[string]string } // DeletedBranchError is returned when a branch was deleted out of band. @@ -146,6 +150,7 @@ func (s *Service) LookupBranch(ctx context.Context, name string) (*LookupBranchR UpstreamBranch: resp.UpstreamBranch, Head: head, MergedDownstack: resp.MergedDownstack, + Submodules: resp.Submodules, } if resp.ChangeMetadata != nil { @@ -410,6 +415,10 @@ type LoadBranchItem struct { // MergedDownstack contains information about any branches, // which this one was based on, that have already been merged into trunk. MergedDownstack []json.RawMessage + + // Submodules maps submodule paths + // to associated branch names. + Submodules map[string]string } // LoadBranches loads all tracked branches @@ -458,6 +467,7 @@ func (s *Service) LoadBranches(ctx context.Context) ([]LoadBranchItem, error) { UpstreamBranch: resp.UpstreamBranch, Change: resp.Change, MergedDownstack: resp.MergedDownstack, + Submodules: resp.Submodules, }) mu.Unlock() } diff --git a/internal/spice/config.go b/internal/spice/config.go index a249f993b..51e8524eb 100644 --- a/internal/spice/config.go +++ b/internal/spice/config.go @@ -236,6 +236,16 @@ func (c *Config) Shorthands() []string { return slices.Sorted(maps.Keys(c.shorthands)) } +// SubmoduleExclusions returns the list of submodule paths +// excluded from recursive operations via +// spice.submodule.exclude in git-config. +func (c *Config) SubmoduleExclusions() []string { + key := git.ConfigKey( + _spiceSection + ".submodule.exclude", + ).Canonical() + return c.items[key] +} + // Validate checks if the configuration is valid for the given application. // This is a no-op, as we allow unknown configuration keys. func (*Config) Validate(*kong.Application) error { return nil } diff --git a/internal/spice/state/branch.go b/internal/spice/state/branch.go index 44e02e1f2..19a396358 100644 --- a/internal/spice/state/branch.go +++ b/internal/spice/state/branch.go @@ -61,11 +61,21 @@ func (bs *branchChangeState) UnmarshalJSON(data []byte) error { return nil } +// submoduleBranch records which branch a submodule +// should be on when this parent branch is checked out. +type submoduleBranch struct { + Branch string `json:"branch"` +} + type branchState struct { Base branchStateBase `json:"base"` Upstream *branchUpstreamState `json:"upstream,omitempty"` Change *branchChangeState `json:"change,omitempty"` + // Submodules maps submodule paths to their + // associated branch in the submodule repo. + Submodules map[string]*submoduleBranch `json:"submodules,omitempty"` + MergedDownstack []json.RawMessage `json:"merged,omitempty"` } @@ -99,6 +109,11 @@ type LookupResponse struct { // or an empty string if the branch is not tracking an upstream branch. UpstreamBranch string + // Submodules maps submodule paths to their + // associated branch name in the submodule repo. + // Empty when no submodule associations exist. + Submodules map[string]string + // MergedDownstack holds information about branches // that were previously downstack from this branch // that have since been merged into trunk. @@ -132,6 +147,13 @@ func (s *Store) LookupBranch(ctx context.Context, name string) (*LookupResponse, res.UpstreamBranch = upstream.Branch } + if len(state.Submodules) > 0 { + res.Submodules = make(map[string]string, len(state.Submodules)) + for path, sub := range state.Submodules { + res.Submodules[path] = sub.Branch + } + } + return res, nil } @@ -229,6 +251,13 @@ type UpsertRequest struct { // Leave nil to leave it unchanged, or set to an empty string to clear it. UpstreamBranch *string + // Submodules maps submodule paths to associated branch names. + // nil = leave unchanged. + // Non-nil = merge into existing: + // non-empty values set an entry, + // empty string values remove that entry. + Submodules map[string]string + // MergedDownstack is a list of branches that were previously // downstack from this branch that have since been merged into trunk. MergedDownstack *[]json.RawMessage @@ -310,6 +339,28 @@ func (tx *BranchTx) Upsert(ctx context.Context, req UpsertRequest) error { } } + if req.Submodules != nil { + if state.Submodules == nil { + state.Submodules = make( + map[string]*submoduleBranch, + len(req.Submodules), + ) + } + for path, branch := range req.Submodules { + if branch == "" { + delete(state.Submodules, path) + } else { + state.Submodules[path] = &submoduleBranch{ + Branch: branch, + } + } + } + // Clear the map entirely if all entries were removed. + if len(state.Submodules) == 0 { + state.Submodules = nil + } + } + if req.MergedDownstack != nil { state.MergedDownstack = *req.MergedDownstack } diff --git a/internal/spice/state/branch_int_test.go b/internal/spice/state/branch_int_test.go index a821d2406..c08d2e8bb 100644 --- a/internal/spice/state/branch_int_test.go +++ b/internal/spice/state/branch_int_test.go @@ -97,6 +97,27 @@ func TestBranchStateUnmarshal(t *testing.T) { }, }, }, + + { + name: "WithSubmodules", + give: `{ + "base": {"name": "main", "hash": "abc123"}, + "submodules": { + "libs/core": {"branch": "feature-x"}, + "libs/util": {"branch": "fix-y"} + } + }`, + want: &branchState{ + Base: branchStateBase{ + Name: "main", + Hash: "abc123", + }, + Submodules: map[string]*submoduleBranch{ + "libs/core": {Branch: "feature-x"}, + "libs/util": {Branch: "fix-y"}, + }, + }, + }, } for _, tt := range tests { diff --git a/internal/spice/state/branch_test.go b/internal/spice/state/branch_test.go index a279bc5a0..6326f2208 100644 --- a/internal/spice/state/branch_test.go +++ b/internal/spice/state/branch_test.go @@ -287,6 +287,129 @@ func TestBranchTxUpsert_canClearUpstream(t *testing.T) { assert.Equal(t, "", foo.UpstreamBranch) } +func TestBranchTxUpsert_submodules(t *testing.T) { + ctx := t.Context() + db := storage.NewDB(make(storage.MapBackend)) + store, err := state.InitStore(ctx, state.InitStoreRequest{ + DB: db, + Trunk: "main", + }) + require.NoError(t, err) + + // Set initial submodule associations. + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "feat", + Base: "main", + Submodules: map[string]string{ + "libs/core": "feat-core", + "libs/util": "feat-util", + }, + }}, + Message: "add feat", + })) + + t.Run("InitialSet", func(t *testing.T) { + res, err := store.LookupBranch(t.Context(), "feat") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "libs/core": "feat-core", + "libs/util": "feat-util", + }, res.Submodules) + }) + + t.Run("MergeNewEntry", func(t *testing.T) { + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "feat", + Submodules: map[string]string{ + "libs/new": "feat-new", + }, + }}, + Message: "add new submodule", + })) + + res, err := store.LookupBranch(t.Context(), "feat") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "libs/core": "feat-core", + "libs/util": "feat-util", + "libs/new": "feat-new", + }, res.Submodules) + }) + + t.Run("OverwriteEntry", func(t *testing.T) { + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "feat", + Submodules: map[string]string{ + "libs/core": "feat-core-v2", + }, + }}, + Message: "repoint submodule", + })) + + res, err := store.LookupBranch(t.Context(), "feat") + require.NoError(t, err) + assert.Equal(t, "feat-core-v2", res.Submodules["libs/core"]) + assert.Equal(t, "feat-util", res.Submodules["libs/util"]) + assert.Equal(t, "feat-new", res.Submodules["libs/new"]) + }) + + t.Run("RemoveEntry", func(t *testing.T) { + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "feat", + Submodules: map[string]string{ + "libs/new": "", // empty = remove + }, + }}, + Message: "remove submodule", + })) + + res, err := store.LookupBranch(t.Context(), "feat") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "libs/core": "feat-core-v2", + "libs/util": "feat-util", + }, res.Submodules) + }) + + t.Run("NilLeavesUnchanged", func(t *testing.T) { + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "feat", + // Submodules: nil — should not change + }}, + Message: "unrelated update", + })) + + res, err := store.LookupBranch(t.Context(), "feat") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "libs/core": "feat-core-v2", + "libs/util": "feat-util", + }, res.Submodules) + }) + + t.Run("RemoveAllClears", func(t *testing.T) { + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "feat", + Submodules: map[string]string{ + "libs/core": "", + "libs/util": "", + }, + }}, + Message: "remove all submodules", + })) + + res, err := store.LookupBranch(t.Context(), "feat") + require.NoError(t, err) + assert.Empty(t, res.Submodules) + }) +} + // Uses rapid to run randomized scenarios on the branch state // to ensure we never leave it in a corrupted state. func TestBranchStateUncorruptible(t *testing.T) { diff --git a/internal/spice/state/store_test.go b/internal/spice/state/store_test.go index ba18d3bae..827040220 100644 --- a/internal/spice/state/store_test.go +++ b/internal/spice/state/store_test.go @@ -107,6 +107,31 @@ func TestStore(t *testing.T) { assert.JSONEq(t, `{"id": 44}`, string(res.ChangeMetadata)) }) + t.Run("submodule associations", func(t *testing.T) { + ctx := t.Context() + err := statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{{ + Name: "with-subs", + Base: "main", + BaseHash: "abcdef", + Submodules: map[string]string{ + "libs/core": "feature-x", + "libs/util": "fix-y", + }, + }}, + }) + require.NoError(t, err) + + res, err := store.LookupBranch(ctx, "with-subs") + require.NoError(t, err) + + assert.Equal(t, "main", res.Base) + assert.Equal(t, map[string]string{ + "libs/core": "feature-x", + "libs/util": "fix-y", + }, res.Submodules) + }) + t.Run("upstream branch", func(t *testing.T) { ctx := t.Context() upstream := "remoteBranch" diff --git a/internal/ui/branchtree/tree.go b/internal/ui/branchtree/tree.go index 14334d0f5..7b5640704 100644 --- a/internal/ui/branchtree/tree.go +++ b/internal/ui/branchtree/tree.go @@ -4,7 +4,9 @@ import ( "cmp" "fmt" "io" + "maps" "path/filepath" + "slices" "strings" "charm.land/lipgloss/v2" @@ -78,6 +80,11 @@ type Item struct { // Style depends on Highlighted: normal if true, faint otherwise. Commits []commit.Summary + // Submodules maps submodule paths + // to associated branch names. + // Rendered as "[path:branch, ...]" when non-empty. + Submodules map[string]string + // NeedsRestack indicates whether the branch needs restacking. // If true, renders the needs-restack indicator. NeedsRestack bool @@ -341,6 +348,10 @@ func (r *branchTreeRenderer) item(sb *strings.Builder, item *Item) { r.worktree(sb, item.Worktree, item.WorktreeHighlights) } + if len(item.Submodules) > 0 { + r.submodules(sb, item.Submodules) + } + if item.NeedsRestack { sb.WriteString(" ") sb.WriteString(r.Style.NeedsRestack.String()) @@ -476,6 +487,24 @@ func (r *branchTreeRenderer) worktree( renderTextWithHighlights(sb, wt, highlights, r.Style.Worktree, r.Style.TextHighlight) } +func (r *branchTreeRenderer) submodules( + sb *strings.Builder, + subs map[string]string, +) { + sb.WriteString(r.Style.Worktree.Render(" [")) + defer sb.WriteString(r.Style.Worktree.Render("]")) + + paths := slices.Sorted(maps.Keys(subs)) + for i, path := range paths { + if i > 0 { + sb.WriteString(r.Style.Worktree.Render(", ")) + } + sb.WriteString(r.Style.Worktree.Render( + path + ":" + subs[path], + )) + } +} + func (r *branchTreeRenderer) pushStatus( sb *strings.Builder, status PushStatus, diff --git a/log.go b/log.go index 58c03dac3..975290730 100644 --- a/log.go +++ b/log.go @@ -215,6 +215,8 @@ func (p *graphLogPresenter) Present(res *list.BranchesResponse, currentBranch st } } + item.Submodules = b.Submodules + if s := b.PushStatus; s != nil { item.PushStatus = branchtree.PushStatus{ Ahead: s.Ahead, @@ -350,6 +352,8 @@ func (p *jsonLogPresenter) Present(res *list.BranchesResponse, currentBranch str logBranch.Worktree = wt } + logBranch.Submodules = branch.Submodules + if err := enc.Encode(logBranch); err != nil { return fmt.Errorf("encode branch %q: %w", branch.Name, err) } @@ -397,6 +401,10 @@ type jsonLogBranch struct { // in any worktree, // or if it's the current branch (current is true). Worktree string `json:"worktree,omitempty"` + + // Submodules maps submodule paths to associated + // branch names. Omitted when empty. + Submodules map[string]string `json:"submodules,omitempty"` } type jsonLogDown struct { diff --git a/main.go b/main.go index d020ea4cd..031dd5955 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ import ( "go.abhg.dev/gs/internal/handler/split" "go.abhg.dev/gs/internal/handler/squash" "go.abhg.dev/gs/internal/handler/submit" + "go.abhg.dev/gs/internal/handler/submodule" "go.abhg.dev/gs/internal/handler/sync" "go.abhg.dev/gs/internal/handler/track" "go.abhg.dev/gs/internal/secret" @@ -171,7 +172,7 @@ func main() { kong.Name(cmdName), kong.Description("git-spice is a command line tool for stacking Git branches."), kong.Resolvers(spiceConfig), - kong.Bind(logger, &forges, &sigStack, &cmd), + kong.Bind(logger, &forges, &sigStack, &cmd, spiceConfig), kong.BindTo(&cmd.Forge, (*forgeOptions)(nil)), kong.BindTo(ctx, (*context.Context)(nil)), kong.BindTo(spiceConfig, (*experiment.Enabler)(nil)), @@ -518,6 +519,23 @@ func (cmd *mainCmd) AfterApply( Restack: restackHandler, }, nil }), + kctx.BindSingletonProvider(func( + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + cfg *spice.Config, + ) (SubmoduleTracker, error) { + var exclude []string + if cfg != nil { + exclude = cfg.SubmoduleExclusions() + } + return &submodule.Tracker{ + Log: log, + Worktree: wt, + Store: store, + Exclude: exclude, + }, nil + }), kctx.BindSingletonProvider(func( log *silog.Logger, store *state.Store, diff --git a/testdata/help/branch_submodule_list.txt b/testdata/help/branch_submodule_list.txt new file mode 100644 index 000000000..0e171ca9a --- /dev/null +++ b/testdata/help/branch_submodule_list.txt @@ -0,0 +1,17 @@ +Usage: gs branch (b) submodule (sm) list (ls) [flags] + +List submodule branch associations + +Shows the submodule branch associations recorded for the given branch. If no +branch is specified, the current branch is used. + +Flags: + -b, --branch=BRANCH Branch to list associations for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_submodule_repoint.txt b/testdata/help/branch_submodule_repoint.txt new file mode 100644 index 000000000..1c5906be9 --- /dev/null +++ b/testdata/help/branch_submodule_repoint.txt @@ -0,0 +1,21 @@ +Usage: gs branch (b) submodule (sm) repoint [flags] + +Change submodule branch association for the current branch + +Changes which submodule branch is associated with the current parent branch. + +If --branch is not specified, the submodule's current branch is used. + +Arguments: + Submodule path to repoint. + +Flags: + -b, --branch=BRANCH Submodule branch to associate. Defaults to submodule's + current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 43406fde2..f96bfdb3e 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,7 +31,6 @@ Log Stack stack (s) submit (s) Submit a stack - stack (s) merge (m) Merge a stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack @@ -59,8 +58,12 @@ Branch branch (b) restack (r) Restack a branch branch (b) onto (on) Move a branch onto another branch branch (b) diff (di) Show diff between a branch and its base - branch (b) merge (m) Merge a branch into trunk branch (b) submit (s) Submit a branch + branch (b) submodule (sm) list (ls) + List submodule branch associations + branch (b) submodule (sm) repoint + Change submodule branch association for the + current branch Commit commit (c) create (c) Create a new commit diff --git a/testdata/script/submodule_list.txt b/testdata/script/submodule_list.txt new file mode 100644 index 000000000..843e1b8c7 --- /dev/null +++ b/testdata/script/submodule_list.txt @@ -0,0 +1,63 @@ +# 'gs branch submodule list' shows submodule branch associations +# recorded for a branch. + +as 'Test ' +at '2026-03-11T15:00:00Z' + +# Set up a submodule repo. +cd sub-repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Create a branch in the submodule repo. +git add feature.txt +gs branch create sub-feat -m 'Add feature' + +# Set up the parent repo. +cd $WORK/repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Add the submodule to the parent repo. +git -c protocol.file.allow=always submodule add $WORK/sub-repo libs/core +git commit -m 'Add submodule' + +# Check out the feature branch in the submodule. +cd libs/core +git checkout sub-feat +cd $WORK/repo + +# Create a branch on the parent, recording submodule state. +git add file.txt +gs branch create feat-x -m 'Add file' + +# List submodule associations for the current branch. +gs branch submodule list +cmp stderr $WORK/golden/list.txt + +# List with explicit branch flag. +gs branch submodule list -b feat-x +cmp stderr $WORK/golden/list.txt + +# Detach the submodule HEAD so no association is recorded. +cd libs/core +git checkout --detach HEAD +cd $WORK/repo + +gs trunk +git add other.txt +gs branch create no-subs -m 'No submodule changes' + +gs branch submodule list +stderr 'no submodule associations' + +-- sub-repo/feature.txt -- +submodule feature +-- repo/file.txt -- +parent file +-- repo/other.txt -- +other file +-- golden/list.txt -- +INF libs/core → sub-feat diff --git a/testdata/script/submodule_log_display.txt b/testdata/script/submodule_log_display.txt new file mode 100644 index 000000000..602253683 --- /dev/null +++ b/testdata/script/submodule_log_display.txt @@ -0,0 +1,47 @@ +# 'gs ls' and 'gs ll' show submodule branch associations +# alongside branch names. + +as 'Test ' +at '2026-03-11T15:00:00Z' + +# Set up a submodule repo. +cd sub-repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init +git add feature.txt +gs branch create sub-feat -m 'Add feature' + +# Set up the parent repo. +cd $WORK/repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Add the submodule. +git -c protocol.file.allow=always submodule add $WORK/sub-repo libs/core +git commit -m 'Add submodule' + +# Check out a branch in the submodule. +cd libs/core +git checkout sub-feat +cd $WORK/repo + +# Create a branch with submodule association. +git add file.txt +gs branch create feat-x -m 'Add file' + +gs ls -a +cmp stderr $WORK/golden/ls.txt + +gs ll -a +stderr 'feat-x \[libs/core:sub-feat\]' +stderr 'Add file' + +-- sub-repo/feature.txt -- +submodule feature +-- repo/file.txt -- +parent file +-- golden/ls.txt -- +┏━■ feat-x [libs/core:sub-feat] ◀ +main diff --git a/testdata/script/submodule_repoint.txt b/testdata/script/submodule_repoint.txt new file mode 100644 index 000000000..1858cefe3 --- /dev/null +++ b/testdata/script/submodule_repoint.txt @@ -0,0 +1,70 @@ +# 'gs branch submodule repoint' changes a submodule branch association +# for the current parent branch. + +as 'Test ' +at '2026-03-11T15:00:00Z' + +# Set up a submodule repo with two branches. +cd sub-repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init +git add feature.txt +gs branch create sub-feat -m 'Add feature' +gs trunk +git add fix.txt +gs branch create sub-fix -m 'Add fix' + +# Set up the parent repo. +cd $WORK/repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Add the submodule. +git -c protocol.file.allow=always submodule add $WORK/sub-repo libs/core +git commit -m 'Add submodule' + +# Check out sub-feat in the submodule. +cd libs/core +git checkout sub-feat +cd $WORK/repo + +# Create a parent branch; records sub-feat association. +git add file.txt +gs branch create feat-x -m 'Add file' + +gs branch submodule list +stderr 'sub-feat' + +# Repoint to a different branch explicitly. +gs branch submodule repoint libs/core -b sub-fix +stderr 'feat-x: libs/core' +stderr 'sub-fix' + +# Verify the association changed. +gs branch submodule list +cmp stderr $WORK/golden/repointed.txt + +# Repoint using the submodule's current branch (no -b flag). +cd libs/core +git checkout sub-feat +cd $WORK/repo + +gs branch submodule repoint libs/core +stderr 'feat-x: libs/core' +stderr 'sub-feat' + +gs branch submodule list +cmp stderr $WORK/golden/original.txt + +-- sub-repo/feature.txt -- +submodule feature +-- sub-repo/fix.txt -- +submodule fix +-- repo/file.txt -- +parent file +-- golden/repointed.txt -- +INF libs/core → sub-fix +-- golden/original.txt -- +INF libs/core → sub-feat diff --git a/testdata/script/submodule_restack_worktree.txt b/testdata/script/submodule_restack_worktree.txt new file mode 100644 index 000000000..8ae7c897e --- /dev/null +++ b/testdata/script/submodule_restack_worktree.txt @@ -0,0 +1,60 @@ +# Verifies that branch restacking works correctly +# when running git-spice inside a submodule +# with an absorbed gitdir. +# +# Git reports %(worktreepath) as the gitdir +# for the main worktree of a submodule, +# which previously caused git-spice to skip restacking +# with "checked out in another worktree". + +as 'Test ' +at '2026-03-24T10:00:00Z' + +# Set up a submodule repo. +cd sub-repo +git init +git commit --allow-empty -m 'Initial commit' + +# Set up the parent repo. +cd $WORK/parent +git init +git commit --allow-empty -m 'Initial commit' + +# Add the submodule and commit. +git -c protocol.file.allow=always submodule add $WORK/sub-repo libs/core +git commit -m 'Add submodule' + +# Work inside the submodule. +cd libs/core +gs repo init + +# Create a stack: main -> feat-a -> feat-b. +cp $WORK/extra/a.txt a.txt +git add a.txt +gs bc feat-a -m 'Add a' +cp $WORK/extra/b.txt b.txt +gs bc feat-b -m 'Add b' + +# Go back to feat-a and amend it. +# 'commit amend' auto-restacks upstack branches. +# This must succeed without the +# "checked out in another worktree" error. +gs bco feat-a +cp $WORK/extra/a2.txt a.txt +git add a.txt +gs commit amend --no-edit +stderr 'feat-b: restacked on feat-a' + +# Verify the stack is intact. +gs ls +stderr 'feat-a' +stderr 'feat-b' + +-- sub-repo/dummy.txt -- +-- parent/dummy.txt -- +-- extra/a.txt -- +a content +-- extra/b.txt -- +b content +-- extra/a2.txt -- +a content updated diff --git a/testdata/script/submodule_track_commit.txt b/testdata/script/submodule_track_commit.txt new file mode 100644 index 000000000..b907a41a1 --- /dev/null +++ b/testdata/script/submodule_track_commit.txt @@ -0,0 +1,60 @@ +# Commit-time recording of submodule branch associations. +# 'gs cc' and 'gs bc' record which submodule branches +# are active when a parent commit is made. + +as 'Test ' +at '2026-03-11T15:00:00Z' + +# Set up a submodule repo. +cd sub-repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init +git add feature.txt +gs branch create sub-feat -m 'Add feature' + +# Set up the parent repo. +cd $WORK/repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Add the submodule. +git -c protocol.file.allow=always submodule add $WORK/sub-repo libs/core +git commit -m 'Add submodule' + +# Check out a branch in the submodule. +cd libs/core +git checkout sub-feat +cd $WORK/repo + +# 'gs branch create' records submodule associations. +git add file1.txt +gs branch create feat-x -m 'Add file1' + +gs branch submodule list +cmp stderr $WORK/golden/list-feat.txt + +# 'gs commit create' also records associations. +cd libs/core +git checkout main +cd $WORK/repo + +cp $WORK/extra/file2.txt file2.txt +git add file2.txt +gs commit create -m 'Add file2' + +# Association updated to reflect the current submodule branch. +gs branch submodule list +cmp stderr $WORK/golden/list-main.txt + +-- sub-repo/feature.txt -- +submodule feature +-- repo/file1.txt -- +parent file 1 +-- extra/file2.txt -- +parent file 2 +-- golden/list-feat.txt -- +INF libs/core → sub-feat +-- golden/list-main.txt -- +INF libs/core → main From 473b7dd0d044569aa24898de0f23a71d472c17cd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:53:13 +0000 Subject: [PATCH 3/4] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 75 ++++++++++++++++++++++++++++++++-- doc/includes/cli-shorthands.md | 2 + testdata/help/gs.txt | 2 + 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index e560a65e0..574870fc1 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,6 +286,48 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice stack merge {#gs-stack-merge} + +``` +gs stack (s) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a stack + +Merges the CRs for the current branch's stack into trunk. +Use --branch to merge a different branch's stack. + +The stack includes the selected branch, +its downstack branches down to trunk, +and every upstack branch. + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +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. + +By default, a branch failure skips that branch's upstack descendants, +but independent sibling branches continue. +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. +* `--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) + ### git-spice stack restack {#gs-stack-restack} ``` @@ -615,11 +657,11 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--branch=NAME`: Branch to start merging from -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. * `--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. +* `--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) @@ -1075,6 +1117,33 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff +### git-spice branch merge {#gs-branch-merge} + +``` +gs branch (b) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch into trunk + +Merges the CR for the current branch into trunk. +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. + +**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. +* `--branch=NAME`: Branch to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice branch submit {#gs-branch-submit} ``` diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 26e157f3b..2a9651998 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -6,6 +6,7 @@ | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | +| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | @@ -34,6 +35,7 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | +| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index f96bfdb3e..01732c84f 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,6 +31,7 @@ Log Stack stack (s) submit (s) Submit a stack + stack (s) merge (m) Merge a stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack @@ -58,6 +59,7 @@ Branch branch (b) restack (r) Restack a branch branch (b) onto (on) Move a branch onto another branch branch (b) diff (di) Show diff between a branch and its base + branch (b) merge (m) Merge a branch into trunk branch (b) submit (s) Submit a branch branch (b) submodule (sm) list (ls) List submodule branch associations From a5cbe4c8692b014e3e44f42219a434de44f9e3ba Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:32:06 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 574870fc1..943dd1742 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -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. @@ -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} @@ -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: @@ -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 @@ -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} @@ -1133,16 +1137,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}