Skip to content

Commit d7c99e4

Browse files
committed
Save selected remote to gh-stack.remote git config
Users with multiple git remotes are prompted to choose a remote on every gh stack operation, which is tedious. This adds the ability to persist that choice so it only needs to be made once. When a user interactively selects a remote (because multiple remotes exist and none is configured as a push default), they are now shown a Y/n follow-up prompt offering to save that remote for all future gh stack operations. If accepted, the choice is written to the local git config key `gh-stack.remote`, and instructions for changing or clearing it are printed. The saved remote is checked in `ResolveRemote` after the standard git push config keys (branch.<name>.pushRemote, remote.pushDefault, branch.<name>.remote) but before falling back to listing all remotes. This means per-branch git push configuration still takes precedence, and the --remote flag on individual commands continues to override everything. All commands that resolve a remote (push, submit, sync, rebase, checkout, link, modify, trunk) go through the shared `pickRemote` helper, so they all benefit automatically. Changes: - Add GetSavedRemote, SaveRemote, ClearRemote to the git Ops interface, defaultOps implementation, public wrappers, and MockOps - Check gh-stack.remote in ResolveRemote's priority chain - Move pickRemote from push.go to utils.go as a shared helper - Add save-remote confirmation prompt after interactive remote selection - Add unit tests for pickRemote save/decline/skip/override flows - Add integration tests for ResolveRemote with saved remote and precedence, and for the SaveRemote/GetSavedRemote/ClearRemote lifecycle
1 parent 3017f37 commit d7c99e4

7 files changed

Lines changed: 331 additions & 42 deletions

File tree

cmd/push.go

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ package cmd
22

33
import (
44
"errors"
5-
"fmt"
65

7-
"github.com/cli/go-gh/v2/pkg/prompter"
86
"github.com/github/gh-stack/internal/config"
97
"github.com/github/gh-stack/internal/git"
108
"github.com/github/gh-stack/internal/modify"
@@ -138,40 +136,3 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
138136
}
139137
return nil
140138
}
141-
142-
// pickRemote determines which remote to push to. If remoteOverride is
143-
// non-empty, it is returned directly. Otherwise it delegates to
144-
// git.ResolveRemote for config-based resolution and remote listing.
145-
// If multiple remotes exist with no configured default, the user is
146-
// prompted to select one interactively.
147-
func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) {
148-
if remoteOverride != "" {
149-
return remoteOverride, nil
150-
}
151-
152-
remote, err := git.ResolveRemote(branch)
153-
if err == nil {
154-
return remote, nil
155-
}
156-
157-
var multi *git.ErrMultipleRemotes
158-
if !errors.As(err, &multi) {
159-
return "", err
160-
}
161-
162-
if !cfg.IsInteractive() {
163-
return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal")
164-
}
165-
166-
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
167-
selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
168-
if promptErr != nil {
169-
if isInterruptError(promptErr) {
170-
clearSelectPrompt(cfg, len(multi.Remotes))
171-
printInterrupt(cfg)
172-
return "", errInterrupt
173-
}
174-
return "", fmt.Errorf("remote selection: %w", promptErr)
175-
}
176-
return multi.Remotes[selected], nil
177-
}

cmd/push_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,106 @@ func TestPush_DoesNotCreatePRs(t *testing.T) {
301301
assert.NoError(t, err)
302302
assert.False(t, createPRCalled, "push should not create PRs")
303303
}
304+
305+
func TestPickRemote_SavesWhenConfirmed(t *testing.T) {
306+
savedRemote := ""
307+
restore := git.SetOps(&git.MockOps{
308+
ResolveRemoteFn: func(string) (string, error) {
309+
return "", &git.ErrMultipleRemotes{Remotes: []string{"origin", "upstream"}}
310+
},
311+
SaveRemoteFn: func(r string) error {
312+
savedRemote = r
313+
return nil
314+
},
315+
})
316+
defer restore()
317+
318+
cfg, outR, errR := config.NewTestConfig()
319+
cfg.ForceInteractive = true
320+
cfg.SelectFn = func(prompt, defaultValue string, options []string) (int, error) {
321+
return 1, nil // select "upstream"
322+
}
323+
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
324+
assert.Contains(t, prompt, "upstream")
325+
assert.True(t, defaultValue)
326+
return true, nil
327+
}
328+
329+
remote, err := pickRemote(cfg, "my-branch", "")
330+
output := collectOutput(cfg, outR, errR)
331+
332+
assert.NoError(t, err)
333+
assert.Equal(t, "upstream", remote)
334+
assert.Equal(t, "upstream", savedRemote)
335+
assert.Contains(t, output, "Saved")
336+
assert.Contains(t, output, "git config gh-stack.remote")
337+
assert.Contains(t, output, "git config --unset gh-stack.remote")
338+
}
339+
340+
func TestPickRemote_SkipsSaveWhenDeclined(t *testing.T) {
341+
saveCalled := false
342+
restore := git.SetOps(&git.MockOps{
343+
ResolveRemoteFn: func(string) (string, error) {
344+
return "", &git.ErrMultipleRemotes{Remotes: []string{"origin", "upstream"}}
345+
},
346+
SaveRemoteFn: func(string) error {
347+
saveCalled = true
348+
return nil
349+
},
350+
})
351+
defer restore()
352+
353+
cfg, outR, errR := config.NewTestConfig()
354+
cfg.ForceInteractive = true
355+
cfg.SelectFn = func(prompt, defaultValue string, options []string) (int, error) {
356+
return 0, nil // select "origin"
357+
}
358+
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
359+
return false, nil
360+
}
361+
362+
remote, err := pickRemote(cfg, "my-branch", "")
363+
output := collectOutput(cfg, outR, errR)
364+
365+
assert.NoError(t, err)
366+
assert.Equal(t, "origin", remote)
367+
assert.False(t, saveCalled, "SaveRemote should not be called when user declines")
368+
assert.NotContains(t, output, "Saved")
369+
}
370+
371+
func TestPickRemote_SkipsPromptWhenSingleRemote(t *testing.T) {
372+
restore := git.SetOps(&git.MockOps{
373+
ResolveRemoteFn: func(string) (string, error) {
374+
return "origin", nil
375+
},
376+
})
377+
defer restore()
378+
379+
cfg, outR, errR := config.NewTestConfig()
380+
381+
remote, err := pickRemote(cfg, "my-branch", "")
382+
collectOutput(cfg, outR, errR)
383+
384+
assert.NoError(t, err)
385+
assert.Equal(t, "origin", remote)
386+
}
387+
388+
func TestPickRemote_OverrideTakesPrecedence(t *testing.T) {
389+
resolveCalled := false
390+
restore := git.SetOps(&git.MockOps{
391+
ResolveRemoteFn: func(string) (string, error) {
392+
resolveCalled = true
393+
return "", fmt.Errorf("should not be called")
394+
},
395+
})
396+
defer restore()
397+
398+
cfg, outR, errR := config.NewTestConfig()
399+
400+
remote, err := pickRemote(cfg, "my-branch", "custom")
401+
collectOutput(cfg, outR, errR)
402+
403+
assert.NoError(t, err)
404+
assert.Equal(t, "custom", remote)
405+
assert.False(t, resolveCalled, "ResolveRemote should not be called when override is provided")
406+
}

cmd/utils.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,3 +1033,88 @@ func ensureRerere(cfg *config.Config) error {
10331033
}
10341034
return nil
10351035
}
1036+
1037+
// pickRemote determines which remote to use. If remoteOverride is
1038+
// non-empty, it is returned directly. Otherwise it delegates to
1039+
// git.ResolveRemote for config-based resolution and remote listing.
1040+
// If multiple remotes exist with no configured default, the user is
1041+
// prompted to select one interactively and offered the option to save
1042+
// the choice via gh-stack.remote git config.
1043+
func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) {
1044+
if remoteOverride != "" {
1045+
return remoteOverride, nil
1046+
}
1047+
1048+
remote, err := git.ResolveRemote(branch)
1049+
if err == nil {
1050+
return remote, nil
1051+
}
1052+
1053+
var multi *git.ErrMultipleRemotes
1054+
if !errors.As(err, &multi) {
1055+
return "", err
1056+
}
1057+
1058+
if !cfg.IsInteractive() {
1059+
return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal")
1060+
}
1061+
1062+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
1063+
selectFn := func(prompt, def string, opts []string) (int, error) {
1064+
if cfg.SelectFn != nil {
1065+
return cfg.SelectFn(prompt, def, opts)
1066+
}
1067+
return p.Select(prompt, def, opts)
1068+
}
1069+
1070+
selected, promptErr := selectFn("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
1071+
if promptErr != nil {
1072+
if isInterruptError(promptErr) {
1073+
if cfg.SelectFn == nil {
1074+
clearSelectPrompt(cfg, len(multi.Remotes))
1075+
}
1076+
printInterrupt(cfg)
1077+
return "", errInterrupt
1078+
}
1079+
return "", fmt.Errorf("remote selection: %w", promptErr)
1080+
}
1081+
selectedRemote := multi.Remotes[selected]
1082+
1083+
// Offer to save the selected remote for future operations.
1084+
save, confirmErr := confirmSaveRemote(cfg, selectedRemote)
1085+
if confirmErr != nil {
1086+
if errors.Is(confirmErr, errInterrupt) {
1087+
return "", errInterrupt
1088+
}
1089+
// Non-fatal: proceed with the selected remote even if the prompt fails.
1090+
return selectedRemote, nil
1091+
}
1092+
if save {
1093+
if saveErr := git.SaveRemote(selectedRemote); saveErr == nil {
1094+
cfg.Successf("Saved %q as the default remote for gh stack", selectedRemote)
1095+
cfg.Printf("To change later, run: %s", cfg.ColorCyan("git config gh-stack.remote <other-remote>"))
1096+
cfg.Printf("To clear, run: %s", cfg.ColorCyan("git config --unset gh-stack.remote"))
1097+
}
1098+
}
1099+
1100+
return selectedRemote, nil
1101+
}
1102+
1103+
// confirmSaveRemote asks the user whether to persist the selected remote
1104+
// for all future gh stack operations. Returns errInterrupt on Ctrl+C.
1105+
func confirmSaveRemote(cfg *config.Config, remote string) (bool, error) {
1106+
prompt := fmt.Sprintf("Save %q as the default remote for all gh stack operations?", remote)
1107+
if cfg.ConfirmFn != nil {
1108+
return cfg.ConfirmFn(prompt, true)
1109+
}
1110+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
1111+
ok, err := p.Confirm(prompt, true)
1112+
if err != nil {
1113+
if isInterruptError(err) {
1114+
printInterrupt(cfg)
1115+
return false, errInterrupt
1116+
}
1117+
return false, err
1118+
}
1119+
return ok, nil
1120+
}

internal/git/git.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,22 @@ func SaveRerereDeclined() error {
190190
return ops.SaveRerereDeclined()
191191
}
192192

193+
// GetSavedRemote returns the remote saved via gh-stack.remote git config,
194+
// or an empty string if none is saved.
195+
func GetSavedRemote() (string, error) {
196+
return ops.GetSavedRemote()
197+
}
198+
199+
// SaveRemote persists the given remote name to gh-stack.remote git config.
200+
func SaveRemote(remote string) error {
201+
return ops.SaveRemote(remote)
202+
}
203+
204+
// ClearRemote removes the gh-stack.remote git config entry.
205+
func ClearRemote() error {
206+
return ops.ClearRemote()
207+
}
208+
193209
// RebaseOnto rebases a branch using the three-argument form:
194210
//
195211
// git rebase --onto <newBase> <oldBase> <branch>

internal/git/gitops.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type Ops interface {
3737
IsRerereEnabled() (bool, error)
3838
IsRerereDeclined() (bool, error)
3939
SaveRerereDeclined() error
40+
GetSavedRemote() (string, error)
41+
SaveRemote(remote string) error
42+
ClearRemote() error
4043
RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error
4144
RebaseContinue(opts RebaseOpts) error
4245
RebaseAbort() error
@@ -197,9 +200,10 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool)
197200

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

220+
// Check gh-stack saved remote preference.
221+
if saved, err := d.GetSavedRemote(); err == nil && saved != "" {
222+
return saved, nil
223+
}
224+
216225
out, err := run("remote")
217226
if err != nil {
218227
return "", fmt.Errorf("could not list remotes: %w", err)
@@ -268,6 +277,22 @@ func (d *defaultOps) SaveRerereDeclined() error {
268277
return runSilent("config", "gh-stack.rerere-declined", "true")
269278
}
270279

280+
func (d *defaultOps) GetSavedRemote() (string, error) {
281+
out, err := run("config", "--get", "gh-stack.remote")
282+
if err != nil {
283+
return "", err
284+
}
285+
return out, nil
286+
}
287+
288+
func (d *defaultOps) SaveRemote(remote string) error {
289+
return runSilent("config", "gh-stack.remote", remote)
290+
}
291+
292+
func (d *defaultOps) ClearRemote() error {
293+
return runSilent("config", "--unset", "gh-stack.remote")
294+
}
295+
271296
func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error {
272297
args := []string{"rebase"}
273298
if opts.CommitterDateIsAuthorDate {

0 commit comments

Comments
 (0)