Skip to content

Commit 756002b

Browse files
authored
ensure local trunk branch for required operations (#127)
* Ensure trunk branch exists locally before commands that need it When a user starts a stack after renaming their initial branch (e.g. `git branch -m newbranch`), the trunk branch (e.g. main) may not exist as a local branch. Commands that pass the trunk name to git operations like merge-base, rebase, or rev-parse then fail with: fatal: Not a valid object name main Add an `ensureLocalTrunk` helper that checks whether the trunk branch exists locally and, if not, fetches it from the remote and creates a local tracking branch. This mirrors the pattern already used in the checkout command for importing stacks. Commands updated: - modify: call ensureLocalTrunk before the linearity check in CheckStackLinearity, which uses IsAncestor(trunk, branch). This was the originally reported failure. - rebase: call ensureLocalTrunk after fetch and before fastForwardTrunk and the cascade rebase. git rebase requires a locally resolvable ref; the remote tracking ref alone is not sufficient. - trunk: call ensureLocalTrunk before CheckoutBranch so that `gh stack trunk` works even when trunk was never created locally. - checkout: refactor the existing inline BranchExists + CreateBranch block to use the shared helper. Also fix an incorrect comment in fastForwardTrunk that claimed "the remote tracking ref is sufficient for rebasing" — verified empirically that `git rebase main` fails when main has no local branch, even after fetching origin/main. Commands that were already safe and required no changes: - sync: fetches trunk explicitly and fastForwardTrunk guards with BranchExists - push, switch, navigate, unstack: do not reference trunk - add, submit: do not require trunk as a local git ref - view: handles IsAncestor errors gracefully (false positive is acceptable since rebase will fix it) * add check to avoid unnecessary remote selection prompt
1 parent f8c0100 commit 756002b

7 files changed

Lines changed: 200 additions & 7 deletions

File tree

cmd/checkout.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -482,12 +482,9 @@ func importRemoteStack(
482482
}
483483

484484
// Ensure trunk exists locally
485-
if !git.BranchExists(trunk) {
486-
remoteTrunk := remote + "/" + trunk
487-
if err := git.CreateBranch(trunk, remoteTrunk); err != nil {
488-
cfg.Errorf("could not create trunk branch %s from %s: %v", trunk, remoteTrunk, err)
489-
return nil, ErrSilent
490-
}
485+
if err := ensureLocalTrunk(cfg, trunk, remote); err != nil {
486+
cfg.Errorf("%s", err)
487+
return nil, ErrSilent
491488
}
492489

493490
// Create local branches for each PR's head branch.

cmd/modify.go

Lines changed: 17 additions & 0 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

@@ -297,6 +298,22 @@ func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) {
297298
return nil, ErrSilent
298299
}
299300

301+
// Ensure trunk branch exists locally (it may be absent if the user
302+
// renamed their initial branch before starting the stack).
303+
if !git.BranchExists(s.Trunk.Branch) {
304+
remote, err := pickRemote(cfg, result.CurrentBranch, "")
305+
if err != nil {
306+
if !errors.Is(err, errInterrupt) {
307+
cfg.Errorf("failed to resolve remote: %s", err)
308+
}
309+
return nil, ErrSilent
310+
}
311+
if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil {
312+
cfg.Errorf("%s", err)
313+
return nil, ErrSilent
314+
}
315+
}
316+
300317
// Show loading indicator while syncing PRs
301318
fmt.Fprintf(cfg.Err, "Loading stack...")
302319

cmd/rebase.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
130130
cfg.Successf("Fetched %s", remote)
131131
}
132132

133+
// Ensure trunk exists locally before fast-forward or cascade rebase.
134+
if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil {
135+
cfg.Errorf("%s", err)
136+
return ErrSilent
137+
}
138+
133139
// Fast-forward trunk so the cascade rebase targets the latest upstream.
134140
fastForwardTrunk(cfg, s.Trunk.Branch, remote, currentBranch)
135141

cmd/trunk.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ func runTrunk(cfg *config.Config) error {
4242
return nil
4343
}
4444

45+
// Ensure trunk exists locally before checkout.
46+
if !git.BranchExists(trunk) {
47+
remote, err := pickRemote(cfg, currentBranch, "")
48+
if err != nil {
49+
if !errors.Is(err, errInterrupt) {
50+
cfg.Errorf("failed to resolve remote: %s", err)
51+
}
52+
return ErrSilent
53+
}
54+
if err := ensureLocalTrunk(cfg, trunk, remote); err != nil {
55+
cfg.Errorf("%s", err)
56+
return ErrSilent
57+
}
58+
}
59+
4560
if err := git.CheckoutBranch(trunk); err != nil {
4661
return err
4762
}

cmd/trunk_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,53 @@ func TestTrunk_RejectsArgs(t *testing.T) {
212212

213213
assert.Error(t, err, "should reject positional arguments")
214214
}
215+
216+
func TestTrunk_MissingLocallyCreatedFromRemote(t *testing.T) {
217+
s := stack.Stack{
218+
Trunk: stack.BranchRef{Branch: "main"},
219+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
220+
}
221+
222+
var checkedOut []string
223+
var createdBranch string
224+
tmpDir := t.TempDir()
225+
writeStackFile(t, tmpDir, s)
226+
227+
mock := &git.MockOps{
228+
GitDirFn: func() (string, error) { return tmpDir, nil },
229+
CurrentBranchFn: func() (string, error) { return "b1", nil },
230+
BranchExistsFn: func(name string) bool {
231+
// trunk does not exist locally
232+
return name != "main"
233+
},
234+
ResolveRemoteFn: func(branch string) (string, error) {
235+
return "origin", nil
236+
},
237+
FetchBranchesFn: func(remote string, branches []string) error {
238+
return nil
239+
},
240+
CreateBranchFn: func(name, base string) error {
241+
createdBranch = name
242+
return nil
243+
},
244+
CheckoutBranchFn: func(name string) error {
245+
checkedOut = append(checkedOut, name)
246+
return nil
247+
},
248+
}
249+
restore := git.SetOps(mock)
250+
defer restore()
251+
252+
cfg, outR, errR := config.NewTestConfig()
253+
cmd := TrunkCmd(cfg)
254+
cmd.SetOut(io.Discard)
255+
cmd.SetErr(io.Discard)
256+
err := cmd.Execute()
257+
258+
output := readCfgOutput(cfg, outR, errR)
259+
260+
assert.NoError(t, err)
261+
assert.Equal(t, "main", createdBranch, "should create trunk from remote")
262+
assert.Equal(t, []string{"main"}, checkedOut)
263+
assert.Contains(t, output, "Created local trunk branch main from origin/main")
264+
}

cmd/utils.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,11 +693,34 @@ func resolveOriginalRefs(s *stack.Stack) (map[string]string, error) {
693693
return originalRefs, nil
694694
}
695695

696+
// ensureLocalTrunk ensures the trunk branch exists locally. If it does not,
697+
// it fetches the branch from the remote and creates a local tracking branch.
698+
// This handles the case where a user started their stack after renaming their
699+
// initial branch (e.g. `git branch -m newbranch`), leaving no local trunk.
700+
func ensureLocalTrunk(cfg *config.Config, trunk, remote string) error {
701+
if git.BranchExists(trunk) {
702+
return nil
703+
}
704+
705+
if err := git.FetchBranches(remote, []string{trunk}); err != nil {
706+
return fmt.Errorf("could not fetch trunk branch %s from %s: %w", trunk, remote, err)
707+
}
708+
709+
remoteTrunk := remote + "/" + trunk
710+
if err := git.CreateBranch(trunk, remoteTrunk); err != nil {
711+
return fmt.Errorf("could not create local trunk branch %s from %s: %w", trunk, remoteTrunk, err)
712+
}
713+
714+
cfg.Successf("Created local trunk branch %s from %s", trunk, remoteTrunk)
715+
return nil
716+
}
717+
696718
// fastForwardTrunk fast-forwards the trunk branch to match its remote tracking
697719
// branch. Returns true if trunk was updated.
698720
func fastForwardTrunk(cfg *config.Config, trunk, remote, currentBranch string) bool {
699721
// If the local trunk branch doesn't exist, there's nothing to
700-
// fast-forward. The remote tracking ref is sufficient for rebasing.
722+
// fast-forward. Callers should use ensureLocalTrunk beforehand if
723+
// they need trunk to be resolvable as a local ref.
701724
if !git.BranchExists(trunk) {
702725
return false
703726
}

cmd/utils_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,3 +725,88 @@ func TestWarnStacksUnavailableOrPAT_ShowsNotEnabledForOAuth(t *testing.T) {
725725
assert.Contains(t, output, "Stacked PRs are not enabled for this repository")
726726
assert.NotContains(t, output, "Personal access tokens")
727727
}
728+
729+
func TestEnsureLocalTrunk_AlreadyExists(t *testing.T) {
730+
mock := &git.MockOps{
731+
BranchExistsFn: func(name string) bool {
732+
return name == "main"
733+
},
734+
}
735+
restore := git.SetOps(mock)
736+
defer restore()
737+
738+
cfg, _, _ := config.NewTestConfig()
739+
err := ensureLocalTrunk(cfg, "main", "origin")
740+
assert.NoError(t, err)
741+
}
742+
743+
func TestEnsureLocalTrunk_FetchesAndCreates(t *testing.T) {
744+
var fetchedBranches []string
745+
var createdBranch, createdBase string
746+
747+
mock := &git.MockOps{
748+
BranchExistsFn: func(name string) bool {
749+
return false
750+
},
751+
FetchBranchesFn: func(remote string, branches []string) error {
752+
fetchedBranches = branches
753+
return nil
754+
},
755+
CreateBranchFn: func(name, base string) error {
756+
createdBranch = name
757+
createdBase = base
758+
return nil
759+
},
760+
}
761+
restore := git.SetOps(mock)
762+
defer restore()
763+
764+
cfg, _, _ := config.NewTestConfig()
765+
err := ensureLocalTrunk(cfg, "main", "origin")
766+
767+
assert.NoError(t, err)
768+
assert.Equal(t, []string{"main"}, fetchedBranches)
769+
assert.Equal(t, "main", createdBranch)
770+
assert.Equal(t, "origin/main", createdBase)
771+
}
772+
773+
func TestEnsureLocalTrunk_FetchFails(t *testing.T) {
774+
mock := &git.MockOps{
775+
BranchExistsFn: func(name string) bool {
776+
return false
777+
},
778+
FetchBranchesFn: func(remote string, branches []string) error {
779+
return fmt.Errorf("network error")
780+
},
781+
}
782+
restore := git.SetOps(mock)
783+
defer restore()
784+
785+
cfg, _, _ := config.NewTestConfig()
786+
err := ensureLocalTrunk(cfg, "main", "origin")
787+
788+
assert.Error(t, err)
789+
assert.Contains(t, err.Error(), "could not fetch trunk branch main from origin")
790+
}
791+
792+
func TestEnsureLocalTrunk_CreateFails(t *testing.T) {
793+
mock := &git.MockOps{
794+
BranchExistsFn: func(name string) bool {
795+
return false
796+
},
797+
FetchBranchesFn: func(remote string, branches []string) error {
798+
return nil
799+
},
800+
CreateBranchFn: func(name, base string) error {
801+
return fmt.Errorf("ref not found")
802+
},
803+
}
804+
restore := git.SetOps(mock)
805+
defer restore()
806+
807+
cfg, _, _ := config.NewTestConfig()
808+
err := ensureLocalTrunk(cfg, "main", "origin")
809+
810+
assert.Error(t, err)
811+
assert.Contains(t, err.Error(), "could not create local trunk branch main")
812+
}

0 commit comments

Comments
 (0)