Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260313-074533.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions branch_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion branch_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
20 changes: 20 additions & 0 deletions branch_submodule.go
Original file line number Diff line number Diff line change
@@ -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"`
}
57 changes: 57 additions & 0 deletions branch_submodule_list.go
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions branch_submodule_repoint.go
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 9 additions & 1 deletion commit_amend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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,
})
Expand Down
8 changes: 8 additions & 0 deletions commit_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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,
})
Expand Down
39 changes: 39 additions & 0 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,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 <path> [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}
Expand Down
1 change: 1 addition & 0 deletions doc/includes/cli-shorthands.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| 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) |
Expand Down
2 changes: 1 addition & 1 deletion downstack_restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
38 changes: 37 additions & 1 deletion internal/git/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand Down
Loading
Loading