Files
github__gh-stack/cmd/push.go
T
Sameen Karim 4c05e58b83 cache selected remote (#128)
* 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

* add error message for save failure

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 13:54:20 -04:00

139 lines
3.9 KiB
Go

package cmd
import (
"errors"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
)
type pushOptions struct {
remote string
}
func PushCmd(cfg *config.Config) *cobra.Command {
opts := &pushOptions{}
cmd := &cobra.Command{
Use: "push",
Short: "Push all branches in the current stack to the remote",
Long: `Push all branches in the current stack to the remote.
Uses --force-with-lease and --atomic to ensure safe, all-or-nothing pushes.
Merged and queued branches are automatically skipped. This command is safe to
run repeatedly — it will only update branches that have changed.`,
Example: ` # Push all stack branches to the default remote
$ gh stack push
# Push to a specific remote
$ gh stack push --remote upstream`,
RunE: func(cmd *cobra.Command, args []string) error {
return runPush(cfg, opts)
},
}
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)")
return cmd
}
func runPush(cfg *config.Config, opts *pushOptions) error {
gitDir, err := git.GitDir()
if err != nil {
cfg.Errorf("not a git repository")
return ErrNotInStack
}
if err := modify.CheckStateGuard(gitDir); err != nil {
cfg.Errorf("%s", err)
return ErrModifyRecovery
}
sf, err := stack.Load(gitDir)
if err != nil {
cfg.Errorf("failed to load stack state: %s", err)
return ErrNotInStack
}
currentBranch, err := git.CurrentBranch()
if err != nil {
cfg.Errorf("failed to get current branch: %s", err)
return ErrNotInStack
}
// Find the stack for the current branch without switching branches.
// Push should never change the user's checked-out branch.
stacks := sf.FindAllStacksForBranch(currentBranch)
if len(stacks) == 0 {
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
return ErrNotInStack
}
if len(stacks) > 1 {
cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch)
return ErrDisambiguate
}
s := stacks[0]
// Push all active branches atomically
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
if !errors.Is(err, errInterrupt) {
cfg.Errorf("%s", err)
}
return ErrSilent
}
// Sync PR state to detect merged/queued PRs before pushing.
_ = syncStackPRs(cfg, s)
merged := s.MergedBranches()
if len(merged) > 0 {
cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches"))
}
queued := s.QueuedBranches()
if len(queued) > 0 {
cfg.Printf("Skipping %d queued %s", len(queued), plural(len(queued), "branch", "branches"))
}
activeBranches := activeBranchNames(s)
if len(activeBranches) == 0 {
cfg.Printf("No active branches to push (all merged or queued)")
return nil
}
// Best-effort fetch to update tracking refs (helps --force-with-lease
// in shallow clones). Silently ignored if branches don't exist on the
// remote yet.
_ = git.FetchBranches(remote, activeBranches)
cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote)
if err := git.Push(remote, activeBranches, true, false); err != nil {
cfg.Errorf("failed to push: %s", err)
return ErrSilent
}
// Update base commit hashes after push
updateBaseSHAs(s)
if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
}
cfg.Successf("Pushed %d branches", len(activeBranches))
// Hint about submit only if there are branches without PRs
hasBranchWithoutPR := false
for _, b := range s.ActiveBranches() {
if b.PullRequest == nil {
hasBranchWithoutPR = true
break
}
}
if hasBranchWithoutPR {
cfg.Printf("To create PRs for this stack, run `%s`",
cfg.ColorCyan("gh stack submit"))
} else {
cfg.Printf("Run `%s` to see your stack of PRs", cfg.ColorCyan("gh stack view"))
}
return nil
}