mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
756002b4c9
* 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
265 lines
6.6 KiB
Go
265 lines
6.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestTrunk_FromMiddleBranch(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}},
|
|
}
|
|
|
|
var checkedOut []string
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b2", nil },
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = append(checkedOut, name)
|
|
return nil
|
|
},
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"main"}, checkedOut)
|
|
}
|
|
|
|
func TestTrunk_AlreadyOnTrunk(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
|
|
var checkedOut []string
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "main", nil },
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = append(checkedOut, name)
|
|
return nil
|
|
},
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
output := readCfgOutput(cfg, outR, errR)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, checkedOut, "should not checkout any branch")
|
|
assert.Contains(t, output, "Already on trunk branch main")
|
|
}
|
|
|
|
func TestTrunk_FromTopOfStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}},
|
|
}
|
|
|
|
var checkedOut []string
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b3", nil },
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = append(checkedOut, name)
|
|
return nil
|
|
},
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"main"}, checkedOut)
|
|
}
|
|
|
|
func TestTrunk_NotInStack(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
// No stack file written — empty git dir
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "some-branch", nil },
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.ErrorIs(t, err, ErrNotInStack)
|
|
}
|
|
|
|
func TestTrunk_CheckoutFailure(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
CheckoutBranchFn: func(name string) error {
|
|
return fmt.Errorf("checkout failed: uncommitted changes")
|
|
},
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.Error(t, err)
|
|
assert.ErrorContains(t, err, "checkout failed")
|
|
}
|
|
|
|
func TestTrunk_CustomTrunkBranch(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "develop"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
|
|
var checkedOut []string
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = append(checkedOut, name)
|
|
return nil
|
|
},
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"develop"}, checkedOut)
|
|
}
|
|
|
|
func TestTrunk_RejectsArgs(t *testing.T) {
|
|
// Ensure trunk does not accept arguments
|
|
tmpDir := t.TempDir()
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
}
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetArgs([]string{"unexpected-arg"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.Error(t, err, "should reject positional arguments")
|
|
}
|
|
|
|
func TestTrunk_MissingLocallyCreatedFromRemote(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
|
|
var checkedOut []string
|
|
var createdBranch string
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
BranchExistsFn: func(name string) bool {
|
|
// trunk does not exist locally
|
|
return name != "main"
|
|
},
|
|
ResolveRemoteFn: func(branch string) (string, error) {
|
|
return "origin", nil
|
|
},
|
|
FetchBranchesFn: func(remote string, branches []string) error {
|
|
return nil
|
|
},
|
|
CreateBranchFn: func(name, base string) error {
|
|
createdBranch = name
|
|
return nil
|
|
},
|
|
CheckoutBranchFn: func(name string) error {
|
|
checkedOut = append(checkedOut, name)
|
|
return nil
|
|
},
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cmd := TrunkCmd(cfg)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
output := readCfgOutput(cfg, outR, errR)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "main", createdBranch, "should create trunk from remote")
|
|
assert.Equal(t, []string{"main"}, checkedOut)
|
|
assert.Contains(t, output, "Created local trunk branch main from origin/main")
|
|
}
|