Skip to content
Open
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
39 changes: 0 additions & 39 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package cmd

import (
"errors"
"fmt"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/modify"
Expand Down Expand Up @@ -138,40 +136,3 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
}
return nil
}

// pickRemote determines which remote to push to. If remoteOverride is
// non-empty, it is returned directly. Otherwise it delegates to
// git.ResolveRemote for config-based resolution and remote listing.
// If multiple remotes exist with no configured default, the user is
// prompted to select one interactively.
func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) {
if remoteOverride != "" {
return remoteOverride, nil
}

remote, err := git.ResolveRemote(branch)
if err == nil {
return remote, nil
}

var multi *git.ErrMultipleRemotes
if !errors.As(err, &multi) {
return "", err
}

if !cfg.IsInteractive() {
return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal")
}

p := prompter.New(cfg.In, cfg.Out, cfg.Err)
selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
if promptErr != nil {
if isInterruptError(promptErr) {
clearSelectPrompt(cfg, len(multi.Remotes))
printInterrupt(cfg)
return "", errInterrupt
}
return "", fmt.Errorf("remote selection: %w", promptErr)
}
return multi.Remotes[selected], nil
}
103 changes: 103 additions & 0 deletions cmd/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,106 @@ func TestPush_DoesNotCreatePRs(t *testing.T) {
assert.NoError(t, err)
assert.False(t, createPRCalled, "push should not create PRs")
}

func TestPickRemote_SavesWhenConfirmed(t *testing.T) {
savedRemote := ""
restore := git.SetOps(&git.MockOps{
ResolveRemoteFn: func(string) (string, error) {
return "", &git.ErrMultipleRemotes{Remotes: []string{"origin", "upstream"}}
},
SaveRemoteFn: func(r string) error {
savedRemote = r
return nil
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
cfg.ForceInteractive = true
cfg.SelectFn = func(prompt, defaultValue string, options []string) (int, error) {
return 1, nil // select "upstream"
}
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
assert.Contains(t, prompt, "upstream")
assert.True(t, defaultValue)
return true, nil
}

remote, err := pickRemote(cfg, "my-branch", "")
output := collectOutput(cfg, outR, errR)

assert.NoError(t, err)
assert.Equal(t, "upstream", remote)
assert.Equal(t, "upstream", savedRemote)
assert.Contains(t, output, "Saved")
assert.Contains(t, output, "git config gh-stack.remote")
assert.Contains(t, output, "git config --unset gh-stack.remote")
}

func TestPickRemote_SkipsSaveWhenDeclined(t *testing.T) {
saveCalled := false
restore := git.SetOps(&git.MockOps{
ResolveRemoteFn: func(string) (string, error) {
return "", &git.ErrMultipleRemotes{Remotes: []string{"origin", "upstream"}}
},
SaveRemoteFn: func(string) error {
saveCalled = true
return nil
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
cfg.ForceInteractive = true
cfg.SelectFn = func(prompt, defaultValue string, options []string) (int, error) {
return 0, nil // select "origin"
}
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
return false, nil
}

remote, err := pickRemote(cfg, "my-branch", "")
output := collectOutput(cfg, outR, errR)

assert.NoError(t, err)
assert.Equal(t, "origin", remote)
assert.False(t, saveCalled, "SaveRemote should not be called when user declines")
assert.NotContains(t, output, "Saved")
}

func TestPickRemote_SkipsPromptWhenSingleRemote(t *testing.T) {
restore := git.SetOps(&git.MockOps{
ResolveRemoteFn: func(string) (string, error) {
return "origin", nil
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()

remote, err := pickRemote(cfg, "my-branch", "")
collectOutput(cfg, outR, errR)

assert.NoError(t, err)
assert.Equal(t, "origin", remote)
}

func TestPickRemote_OverrideTakesPrecedence(t *testing.T) {
resolveCalled := false
restore := git.SetOps(&git.MockOps{
ResolveRemoteFn: func(string) (string, error) {
resolveCalled = true
return "", fmt.Errorf("should not be called")
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()

remote, err := pickRemote(cfg, "my-branch", "custom")
collectOutput(cfg, outR, errR)

assert.NoError(t, err)
assert.Equal(t, "custom", remote)
assert.False(t, resolveCalled, "ResolveRemote should not be called when override is provided")
}
87 changes: 87 additions & 0 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -1033,3 +1033,90 @@ func ensureRerere(cfg *config.Config) error {
}
return nil
}

// pickRemote determines which remote to use. If remoteOverride is
// non-empty, it is returned directly. Otherwise it delegates to
// git.ResolveRemote for config-based resolution and remote listing.
// If multiple remotes exist with no configured default, the user is
// prompted to select one interactively and offered the option to save
// the choice via gh-stack.remote git config.
func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) {
if remoteOverride != "" {
return remoteOverride, nil
}

remote, err := git.ResolveRemote(branch)
if err == nil {
return remote, nil
}

var multi *git.ErrMultipleRemotes
if !errors.As(err, &multi) {
return "", err
}

if !cfg.IsInteractive() {
return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal")
}

p := prompter.New(cfg.In, cfg.Out, cfg.Err)
selectFn := func(prompt, def string, opts []string) (int, error) {
if cfg.SelectFn != nil {
return cfg.SelectFn(prompt, def, opts)
}
return p.Select(prompt, def, opts)
}

selected, promptErr := selectFn("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
if promptErr != nil {
if isInterruptError(promptErr) {
if cfg.SelectFn == nil {
clearSelectPrompt(cfg, len(multi.Remotes))
}
printInterrupt(cfg)
return "", errInterrupt
}
return "", fmt.Errorf("remote selection: %w", promptErr)
}
selectedRemote := multi.Remotes[selected]

// Offer to save the selected remote for future operations.
save, confirmErr := confirmSaveRemote(cfg, selectedRemote)
if confirmErr != nil {
if errors.Is(confirmErr, errInterrupt) {
return "", errInterrupt
}
// Non-fatal: proceed with the selected remote even if the prompt fails.
return selectedRemote, nil
}
if save {
if saveErr := git.SaveRemote(selectedRemote); saveErr == nil {
cfg.Successf("Saved %q as the default remote for gh stack", selectedRemote)
cfg.Printf("To change later, run: %s", cfg.ColorCyan("git config gh-stack.remote <other-remote>"))
cfg.Printf("To clear, run: %s", cfg.ColorCyan("git config --unset gh-stack.remote"))
} else {
cfg.Warningf("Could not save remote preference: %v", saveErr)
}
}
Comment thread
skarim marked this conversation as resolved.

return selectedRemote, nil
}

// confirmSaveRemote asks the user whether to persist the selected remote
// for all future gh stack operations. Returns errInterrupt on Ctrl+C.
func confirmSaveRemote(cfg *config.Config, remote string) (bool, error) {
prompt := fmt.Sprintf("Save %q as the default remote for all gh stack operations?", remote)
if cfg.ConfirmFn != nil {
return cfg.ConfirmFn(prompt, true)
}
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
ok, err := p.Confirm(prompt, true)
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return false, errInterrupt
}
return false, err
}
return ok, nil
}
16 changes: 16 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,22 @@ func SaveRerereDeclined() error {
return ops.SaveRerereDeclined()
}

// GetSavedRemote returns the remote saved via gh-stack.remote git config,
// or an error if none is configured.
func GetSavedRemote() (string, error) {
return ops.GetSavedRemote()
}

// SaveRemote persists the given remote name to gh-stack.remote git config.
func SaveRemote(remote string) error {
return ops.SaveRemote(remote)
}

// ClearRemote removes the gh-stack.remote git config entry.
func ClearRemote() error {
return ops.ClearRemote()
}

// RebaseOnto rebases a branch using the three-argument form:
//
// git rebase --onto <newBase> <oldBase> <branch>
Expand Down
31 changes: 28 additions & 3 deletions internal/git/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ type Ops interface {
IsRerereEnabled() (bool, error)
IsRerereDeclined() (bool, error)
SaveRerereDeclined() error
GetSavedRemote() (string, error)
SaveRemote(remote string) error
ClearRemote() error
RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error
RebaseContinue(opts RebaseOpts) error
RebaseAbort() error
Expand Down Expand Up @@ -197,9 +200,10 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool)

// ResolveRemote determines the remote for pushing a branch. It checks git
// config keys in priority order (branch.<name>.pushRemote, remote.pushDefault,
// branch.<name>.remote), then falls back to listing all remotes. If exactly
// one remote exists it is returned. If multiple exist, ErrMultipleRemotes is
// returned with the list attached. If none exist, a plain error is returned.
// branch.<name>.remote), then checks the gh-stack.remote saved preference,
// then falls back to listing all remotes. If exactly one remote exists it is
// returned. If multiple exist, ErrMultipleRemotes is returned with the list
// attached. If none exist, a plain error is returned.
func (d *defaultOps) ResolveRemote(branch string) (string, error) {
candidates := []string{
"branch." + branch + ".pushRemote",
Expand All @@ -213,6 +217,11 @@ func (d *defaultOps) ResolveRemote(branch string) (string, error) {
}
}

// Check gh-stack saved remote preference.
if saved, err := d.GetSavedRemote(); err == nil && saved != "" {
return saved, nil
}

out, err := run("remote")
if err != nil {
return "", fmt.Errorf("could not list remotes: %w", err)
Expand Down Expand Up @@ -268,6 +277,22 @@ func (d *defaultOps) SaveRerereDeclined() error {
return runSilent("config", "gh-stack.rerere-declined", "true")
}

func (d *defaultOps) GetSavedRemote() (string, error) {
out, err := run("config", "--get", "gh-stack.remote")
if err != nil {
return "", err
}
return out, nil
}

func (d *defaultOps) SaveRemote(remote string) error {
return runSilent("config", "gh-stack.remote", remote)
}

func (d *defaultOps) ClearRemote() error {
return runSilent("config", "--unset", "gh-stack.remote")
}

func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error {
args := []string{"rebase"}
if opts.CommitterDateIsAuthorDate {
Expand Down
Loading