Skip to content

Commit 7869aff

Browse files
committed
push all branches atomically and resolve remotes
1 parent 627832d commit 7869aff

6 files changed

Lines changed: 157 additions & 62 deletions

File tree

cmd/push.go

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67

@@ -12,8 +13,8 @@ import (
1213
)
1314

1415
type pushOptions struct {
15-
auto bool
16-
draft bool
16+
auto bool
17+
draft bool
1718
skipPRs bool
1819
}
1920

@@ -54,40 +55,40 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
5455
return nil
5556
}
5657

57-
s, err := resolveStack(sf, currentBranch, cfg)
58-
if err != nil {
59-
cfg.Errorf("%s", err)
60-
return nil
61-
}
62-
if s == nil {
58+
// Find the stack for the current branch without switching branches.
59+
// Push should never change the user's checked-out branch.
60+
stacks := sf.FindAllStacksForBranch(currentBranch)
61+
if len(stacks) == 0 {
6362
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
6463
return nil
6564
}
66-
67-
// Re-read current branch in case disambiguation caused a checkout
68-
currentBranch, err = git.CurrentBranch()
69-
if err != nil {
70-
cfg.Errorf("failed to get current branch: %s", err)
65+
if len(stacks) > 1 {
66+
cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch)
7167
return nil
7268
}
69+
s := stacks[0]
7370

7471
client, err := cfg.GitHubClient()
7572
if err != nil {
7673
cfg.Errorf("failed to create GitHub client: %s", err)
7774
return nil
7875
}
7976

80-
// Push all branches
77+
// Push all active branches atomically
78+
remote, err := pickRemote(cfg, currentBranch)
79+
if err != nil {
80+
cfg.Errorf("%s", err)
81+
return nil
82+
}
8183
merged := s.MergedBranches()
8284
if len(merged) > 0 {
8385
cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches"))
8486
}
85-
for _, b := range s.ActiveBranches() {
86-
cfg.Printf("Pushing %s...", b.Branch)
87-
if err := git.Push("origin", []string{b.Branch}, true, false); err != nil {
88-
cfg.Errorf("failed to push %s: %s", b.Branch, err)
89-
return nil
90-
}
87+
activeBranches := activeBranchNames(s)
88+
cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote)
89+
if err := git.Push(remote, activeBranches, true, true); err != nil {
90+
cfg.Errorf("failed to push: %s", err)
91+
return nil
9192
}
9293

9394
if opts.skipPRs {
@@ -174,18 +175,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
174175
fmt.Fprintf(cfg.Err, " grouped into a Stack.\n")
175176

176177
// Update base commit hashes and sync PR state
177-
for i := range s.Branches {
178-
if s.Branches[i].IsMerged() {
179-
continue
180-
}
181-
parent := s.ActiveBaseBranch(s.Branches[i].Branch)
182-
if base, err := git.HeadSHA(parent); err == nil {
183-
s.Branches[i].Base = base
184-
}
185-
if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil {
186-
s.Branches[i].Head = head
187-
}
188-
}
178+
updateBaseSHAs(s)
189179
syncStackPRs(cfg, s)
190180

191181
if err := stack.Save(gitDir, sf); err != nil {
@@ -235,3 +225,30 @@ func humanize(s string) string {
235225
return r
236226
}, s)
237227
}
228+
229+
// pickRemote determines which remote to push to. It delegates to
230+
// git.ResolveRemote for config-based resolution and remote listing.
231+
// If multiple remotes exist with no configured default, the user is
232+
// prompted to select one interactively.
233+
func pickRemote(cfg *config.Config, branch string) (string, error) {
234+
remote, err := git.ResolveRemote(branch)
235+
if err == nil {
236+
return remote, nil
237+
}
238+
239+
var multi *git.ErrMultipleRemotes
240+
if !errors.As(err, &multi) {
241+
return "", err
242+
}
243+
244+
if !cfg.IsInteractive() {
245+
return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal")
246+
}
247+
248+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
249+
selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
250+
if promptErr != nil {
251+
return "", fmt.Errorf("remote selection: %w", promptErr)
252+
}
253+
return multi.Remotes[selected], nil
254+
}

cmd/sync.go

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ func SyncCmd(cfg *config.Config) *cobra.Command {
2222
2323
This command performs a safe, non-interactive synchronization:
2424
25-
1. Fetches the latest changes from origin
25+
1. Fetches the latest changes from the remote
2626
2. Fast-forwards the trunk branch to match the remote
2727
3. Cascade-rebases stack branches onto their updated parents
28-
4. Pushes all branches (using --force-with-lease)
28+
4. Pushes all branches atomically (using --force-with-lease --atomic)
2929
5. Syncs PR state from GitHub
3030
3131
If a rebase conflict is detected, all branches are restored to their
@@ -75,22 +75,29 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
7575
return nil
7676
}
7777

78+
// Resolve remote once for fetch and push
79+
remote, err := pickRemote(cfg, currentBranch)
80+
if err != nil {
81+
cfg.Errorf("%s", err)
82+
return nil
83+
}
84+
7885
// --- Step 1: Fetch ---
7986
// Enable git rerere so conflict resolutions are remembered.
8087
_ = git.EnableRerere()
8188

82-
if err := git.Fetch("origin"); err != nil {
83-
cfg.Warningf("Failed to fetch origin: %v", err)
89+
if err := git.Fetch(remote); err != nil {
90+
cfg.Warningf("Failed to fetch %s: %v", remote, err)
8491
} else {
85-
cfg.Successf("Fetched latest changes")
92+
cfg.Successf("Fetched latest changes from %s", remote)
8693
}
8794

8895
// --- Step 2: Fast-forward trunk ---
8996
trunk := s.Trunk.Branch
9097
trunkUpdated := false
9198

9299
localSHA, localErr := git.HeadSHA(trunk)
93-
remoteSHA, remoteErr := git.HeadSHA("origin/" + trunk)
100+
remoteSHA, remoteErr := git.HeadSHA(remote + "/" + trunk)
94101

95102
if localErr != nil || remoteErr != nil {
96103
cfg.Warningf("Could not compare trunk %s with remote — skipping trunk update", trunk)
@@ -101,13 +108,12 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
101108
if err != nil {
102109
cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err)
103110
} else if !isAncestor {
104-
cfg.Warningf("Trunk %s has diverged from origin — skipping trunk update", trunk)
111+
cfg.Warningf("Trunk %s has diverged from %s — skipping trunk update", trunk, remote)
105112
cfg.Printf(" Local and remote %s have diverged. Resolve manually.", trunk)
106113
} else {
107114
// Fast-forward the trunk branch
108115
if currentBranch == trunk {
109-
// Can't update ref of checked-out branch; merge instead
110-
if err := ffMerge(trunk); err != nil {
116+
if err := git.MergeFF(remote + "/" + trunk); err != nil {
111117
cfg.Warningf("Failed to fast-forward %s: %v", trunk, err)
112118
} else {
113119
cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA))
@@ -231,12 +237,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
231237

232238
// --- Step 4: Push ---
233239
cfg.Printf("")
234-
var branches []string
235-
for _, b := range s.Branches {
236-
if !b.IsMerged() {
237-
branches = append(branches, b.Branch)
238-
}
239-
}
240+
branches := activeBranchNames(s)
240241

241242
if mergedCount := len(s.MergedBranches()); mergedCount > 0 {
242243
cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches"))
@@ -248,8 +249,8 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
248249
// After rebase, force-with-lease is required (history rewritten).
249250
// Without rebase, try a normal push first.
250251
force := rebased
251-
cfg.Printf("Pushing branches ...")
252-
if err := git.Push("origin", branches, force, false); err != nil {
252+
cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote)
253+
if err := git.Push(remote, branches, force, true); err != nil {
253254
if !force {
254255
cfg.Warningf("Push failed — branches may need force push after rebase")
255256
cfg.Printf(" Run %s to push with --force-with-lease.",
@@ -293,19 +294,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
293294
}
294295

295296
// --- Step 6: Update base SHAs and save ---
296-
for i := range s.Branches {
297-
// Skip merged branches when updating base SHAs.
298-
if s.Branches[i].IsMerged() {
299-
continue
300-
}
301-
parent := s.ActiveBaseBranch(s.Branches[i].Branch)
302-
if base, err := git.HeadSHA(parent); err == nil {
303-
s.Branches[i].Base = base
304-
}
305-
if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil {
306-
s.Branches[i].Head = head
307-
}
308-
}
297+
updateBaseSHAs(s)
309298

310299
if err := stack.Save(gitDir, sf); err != nil {
311300
cfg.Errorf("failed to save stack state: %s", err)

cmd/utils.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,31 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) {
9090
}
9191
}
9292
}
93+
94+
// updateBaseSHAs refreshes the Base and Head SHAs for all active branches
95+
// in a stack. Call this after any operation that may have moved branch refs
96+
// (rebase, push, etc.).
97+
func updateBaseSHAs(s *stack.Stack) {
98+
for i := range s.Branches {
99+
if s.Branches[i].IsMerged() {
100+
continue
101+
}
102+
parent := s.ActiveBaseBranch(s.Branches[i].Branch)
103+
if base, err := git.HeadSHA(parent); err == nil {
104+
s.Branches[i].Base = base
105+
}
106+
if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil {
107+
s.Branches[i].Head = head
108+
}
109+
}
110+
}
111+
112+
// activeBranchNames returns the branch names for all non-merged branches in a stack.
113+
func activeBranchNames(s *stack.Stack) []string {
114+
active := s.ActiveBranches()
115+
names := make([]string, len(active))
116+
for i, b := range active {
117+
names[i] = b.Branch
118+
}
119+
return names
120+
}

internal/git/git.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package git
22

33
import (
44
"context"
5+
"fmt"
56
"os"
67
"os/exec"
78
"strings"
@@ -13,6 +14,16 @@ import (
1314
// client is a shared git client used by all package-level functions.
1415
var client = &cligit.Client{}
1516

17+
// ErrMultipleRemotes is returned by ResolveRemote when multiple remotes
18+
// are configured and none is designated as the push target.
19+
type ErrMultipleRemotes struct {
20+
Remotes []string
21+
}
22+
23+
func (e *ErrMultipleRemotes) Error() string {
24+
return fmt.Sprintf("multiple remotes configured: %s", strings.Join(e.Remotes, ", "))
25+
}
26+
1627
// CommitInfo holds metadata about a single commit.
1728
type CommitInfo struct {
1829
SHA string
@@ -118,6 +129,14 @@ func Push(remote string, branches []string, force, atomic bool) error {
118129
return ops.Push(remote, branches, force, atomic)
119130
}
120131

132+
// ResolveRemote determines the remote for pushing a branch. Checks git
133+
// config in priority order, falls back to listing remotes. Returns
134+
// *ErrMultipleRemotes if multiple remotes exist with no configured default.
135+
func ResolveRemote(branch string) (string, error) {
136+
return ops.ResolveRemote(branch)
137+
}
138+
139+
121140
// Rebase rebases the current branch onto the given base.
122141
// If rerere resolves all conflicts automatically, the rebase continues
123142
// without user intervention.

internal/git/gitops.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package git
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"os"
78
"os/exec"
89
"path/filepath"
@@ -23,6 +24,7 @@ type Ops interface {
2324
DefaultBranch() (string, error)
2425
CreateBranch(name, base string) error
2526
Push(remote string, branches []string, force, atomic bool) error
27+
ResolveRemote(branch string) (string, error)
2628
Rebase(base string) error
2729
EnableRerere() error
2830
RebaseOnto(newBase, oldBase, branch string) error
@@ -122,6 +124,38 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool)
122124
return runSilent(args...)
123125
}
124126

127+
// ResolveRemote determines the remote for pushing a branch. It checks git
128+
// config keys in priority order (branch.<name>.pushRemote, remote.pushDefault,
129+
// branch.<name>.remote), then falls back to listing all remotes. If exactly
130+
// one remote exists it is returned. If multiple exist, ErrMultipleRemotes is
131+
// returned with the list attached. If none exist, a plain error is returned.
132+
func (d *defaultOps) ResolveRemote(branch string) (string, error) {
133+
candidates := []string{
134+
"branch." + branch + ".pushRemote",
135+
"remote.pushDefault",
136+
"branch." + branch + ".remote",
137+
}
138+
for _, key := range candidates {
139+
out, err := run("config", "--get", key)
140+
if err == nil && out != "" {
141+
return out, nil
142+
}
143+
}
144+
145+
out, err := run("remote")
146+
if err != nil {
147+
return "", fmt.Errorf("could not list remotes: %w", err)
148+
}
149+
remotes := strings.Fields(strings.TrimSpace(out))
150+
if len(remotes) == 1 {
151+
return remotes[0], nil
152+
}
153+
if len(remotes) > 1 {
154+
return "", &ErrMultipleRemotes{Remotes: remotes}
155+
}
156+
return "", fmt.Errorf("no remotes configured")
157+
}
158+
125159
func (d *defaultOps) Rebase(base string) error {
126160
err := runSilent("rebase", base)
127161
if err == nil {

internal/git/mock_ops.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type MockOps struct {
1212
DefaultBranchFn func() (string, error)
1313
CreateBranchFn func(string, string) error
1414
PushFn func(string, []string, bool, bool) error
15+
ResolveRemoteFn func(string) (string, error)
1516
RebaseFn func(string) error
1617
EnableRerereFn func() error
1718
RebaseOntoFn func(string, string, string) error
@@ -98,6 +99,13 @@ func (m *MockOps) Push(remote string, branches []string, force, atomic bool) err
9899
return nil
99100
}
100101

102+
func (m *MockOps) ResolveRemote(branch string) (string, error) {
103+
if m.ResolveRemoteFn != nil {
104+
return m.ResolveRemoteFn(branch)
105+
}
106+
return "origin", nil
107+
}
108+
101109
func (m *MockOps) Rebase(base string) error {
102110
if m.RebaseFn != nil {
103111
return m.RebaseFn(base)

0 commit comments

Comments
 (0)