mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
e0b2104e75
* unstack as a pure api wrapper if stack not checked out locally * update unstack docs * address review comments
730 lines
24 KiB
Go
730 lines
24 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/cli/go-gh/v2/pkg/api"
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"github.com/github/gh-stack/internal/github"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func writeTwoStacks(t *testing.T, dir string, s1, s2 stack.Stack) {
|
|
t.Helper()
|
|
sf := &stack.StackFile{
|
|
SchemaVersion: 1,
|
|
Stacks: []stack.Stack{s1, s2},
|
|
}
|
|
data, err := json.MarshalIndent(sf, "", " ")
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644))
|
|
}
|
|
|
|
func TestUnstack_RemovesStack(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
s1 := stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
s2 := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}},
|
|
}
|
|
writeTwoStacks(t, gitDir, s1, s2)
|
|
|
|
var unstackedNumber int
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
|
|
unstackedNumber = n
|
|
return nil, true, nil // dissolved
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "Stack removed from local tracking")
|
|
assert.Contains(t, output, "Stack removed on GitHub")
|
|
assert.Equal(t, 42, unstackedNumber)
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
assert.Equal(t, []string{"b3", "b4"}, sf.Stacks[0].BranchNames())
|
|
}
|
|
|
|
func TestUnstack_Local(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
err := runUnstack(cfg, &unstackOptions{local: true})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "Stack removed")
|
|
// With --local, the GitHub API should NOT be called.
|
|
assert.NotContains(t, output, "Stack removed on GitHub")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, sf.Stacks)
|
|
}
|
|
|
|
func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
// Stack with no ID/Number (never synced to GitHub)
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
apiCalled := false
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
apiCalled = true
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, apiCalled, "API should not be called when stack has no ID")
|
|
assert.Contains(t, output, "no remote ID")
|
|
assert.Contains(t, output, "Stack removed from local tracking")
|
|
assert.NotContains(t, output, "Stack removed on GitHub")
|
|
}
|
|
|
|
func TestUnstack_ResolvesNumberFromID(t *testing.T) {
|
|
// A local stack that predates the Number field (only ID stored) resolves
|
|
// its stack number from the remote list before unstacking.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
var unstackedNumber int
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil
|
|
},
|
|
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
|
|
unstackedNumber = n
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 7, unstackedNumber, "should resolve the stack number from the internal ID")
|
|
assert.Contains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, sf.Stacks)
|
|
}
|
|
|
|
func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Number: 99,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
return nil, false, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
// 404 means already gone — should succeed and remove locally
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "continuing with local unstack")
|
|
assert.Contains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, sf.Stacks)
|
|
}
|
|
|
|
func TestUnstack_ServerError_StopsLocalDeletion(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Number: 99,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
return nil, false, &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"}
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrAPIFailure)
|
|
assert.Contains(t, output, "Failed to unstack on GitHub (HTTP 409)")
|
|
// Should NOT remove locally when remote fails
|
|
assert.NotContains(t, output, "Stack removed from local tracking")
|
|
|
|
// Stack should still exist locally
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_RemovesCorrectStackByPointer(t *testing.T) {
|
|
// Two stacks share the same trunk "main". Current branch "b3" should remove
|
|
// only the second stack (b3,b4), leaving the first (b1,b2) intact.
|
|
// This verifies pointer-based removal instead of branch-name-based.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b3", nil },
|
|
})
|
|
defer restore()
|
|
|
|
s1 := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
s2 := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}},
|
|
}
|
|
writeTwoStacks(t, gitDir, s1, s2)
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
err := runUnstack(cfg, &unstackOptions{local: true})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1, "should remove exactly one stack")
|
|
assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames(), "should keep the OTHER stack intact")
|
|
}
|
|
|
|
func TestUnstack_AllLocked_ServerRejects(t *testing.T) {
|
|
// Every PR is queued for merge or has auto-merge enabled. The server
|
|
// (not the client) rejects the unstack with a 422; the command surfaces the
|
|
// error and leaves local tracking in place.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Number: 99,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
unstackCalled := false
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
unstackCalled = true
|
|
return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"}
|
|
},
|
|
}
|
|
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrInvalidArgs)
|
|
assert.True(t, unstackCalled, "the server decides eligibility, so Unstack is called")
|
|
assert.Contains(t, output, "Unstacking not allowed")
|
|
assert.NotContains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, loadErr := stack.Load(gitDir)
|
|
require.NoError(t, loadErr)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_PartialUnstack_KeepsLocalTracking(t *testing.T) {
|
|
// Some PRs (queued for merge / auto-merge) remain stacked, so the server
|
|
// returns the surviving stack (dissolved=false). Local tracking is kept.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Number: 99,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{102}}, false, nil
|
|
},
|
|
}
|
|
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "remain stacked on GitHub")
|
|
assert.Contains(t, output, "local tracking is unchanged")
|
|
assert.NotContains(t, output, "Stack removed from local tracking")
|
|
|
|
// The stack still exists remotely, so local tracking is preserved.
|
|
sf, loadErr := stack.Load(gitDir)
|
|
require.NoError(t, loadErr)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_NumberLookupFailure_StopsDeletion(t *testing.T) {
|
|
// Resolving the stack number from its ID fails (list API error), so the
|
|
// command aborts without touching local tracking.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}},
|
|
})
|
|
|
|
unstackCalled := false
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return nil, errors.New("network error")
|
|
},
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
unstackCalled = true
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
|
|
err := runUnstack(cfg, &unstackOptions{})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrAPIFailure)
|
|
assert.False(t, unstackCalled, "Unstack should not be called if number lookup fails")
|
|
assert.Contains(t, output, "failed to look up stack on GitHub")
|
|
assert.NotContains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, loadErr := stack.Load(gitDir)
|
|
require.NoError(t, loadErr)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber(t *testing.T) {
|
|
// Target a specific stack by its number, regardless of the current branch.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
s1 := stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
}
|
|
s2 := stack.Stack{
|
|
ID: "99",
|
|
Number: 7,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}},
|
|
}
|
|
writeTwoStacks(t, gitDir, s1, s2)
|
|
|
|
var unstackedNumber int
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
|
|
unstackedNumber = n
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 7})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 7, unstackedNumber)
|
|
assert.Contains(t, output, "Stack removed from local tracking")
|
|
|
|
// The targeted stack (number 7 / b3,b4) is removed; the other is kept.
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames())
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_RemoteOnly_Dissolved(t *testing.T) {
|
|
// A stack number that isn't tracked locally is unstacked directly on GitHub
|
|
// (remote-first), leaving unrelated local tracking untouched.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
var unstackedNumber int
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
|
|
unstackedNumber = n
|
|
return nil, true, nil // dissolved
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 999})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 999, unstackedNumber, "should unstack the requested number on GitHub")
|
|
assert.Contains(t, output, "Stack removed on GitHub")
|
|
// No local tracking was touched.
|
|
assert.NotContains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
assert.Equal(t, 42, sf.Stacks[0].Number, "the unrelated local stack is left intact")
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_RemoteOnly_NotFound(t *testing.T) {
|
|
// With no local tracking to reconcile, a 404 means the targeted stack does
|
|
// not exist on GitHub — a hard error, not an idempotent success.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
return nil, false, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 999})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrNotInStack)
|
|
assert.Contains(t, output, "stack #999 not found on GitHub")
|
|
assert.NotContains(t, output, "Stack removed")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_RemoteOnly_Partial(t *testing.T) {
|
|
// Some PRs (queued for merge / auto-merge) remain stacked. There is no local
|
|
// tracking to keep, so the command reports the outcome and succeeds.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
return &github.RemoteStack{ID: 555, Number: 999, PullRequests: []int{102}}, false, nil
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 999})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "remain stacked on GitHub")
|
|
// No local tracking is involved, so no local-tracking messaging is shown.
|
|
assert.NotContains(t, output, "local tracking is unchanged")
|
|
assert.NotContains(t, output, "Stack removed from local tracking")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_RemoteOnly_AllLocked(t *testing.T) {
|
|
// Every PR is queued for merge or has auto-merge enabled; the server rejects
|
|
// the unstack with a 422 and the command surfaces the error.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"}
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 999})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrInvalidArgs)
|
|
assert.Contains(t, output, "Unstacking not allowed")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_NotTracked_LocalFlag(t *testing.T) {
|
|
// --local never contacts GitHub. Targeting a number that isn't tracked
|
|
// locally with --local is an error: there is nothing to remove locally.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "42",
|
|
Number: 42,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
|
|
})
|
|
|
|
unstackCalled := false
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
unstackCalled = true
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 999, local: true})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrNotInStack)
|
|
assert.False(t, unstackCalled, "--local must never contact GitHub")
|
|
assert.Contains(t, output, "stack #999 is not tracked locally")
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_LocalFlag_LegacyStack_NoRemoteCall(t *testing.T) {
|
|
// --local must never contact GitHub. A legacy stack (Number == 0) can only
|
|
// be matched by number via a remote backfill (ListStacks); under --local
|
|
// that lookup must be skipped and the number reported as not tracked.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
// Legacy: internal ID present, Number unset (0).
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99",
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
listCalled := false
|
|
unstackCalled := false
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
listCalled = true
|
|
return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil
|
|
},
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
unstackCalled = true
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 7, local: true})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
assert.ErrorIs(t, err, ErrNotInStack)
|
|
assert.False(t, listCalled, "--local must not contact GitHub (no ListStacks backfill)")
|
|
assert.False(t, unstackCalled, "--local must not contact GitHub")
|
|
assert.Contains(t, output, "stack #7 is not tracked locally")
|
|
|
|
// Local tracking is untouched.
|
|
sf, loadErr := stack.Load(gitDir)
|
|
require.NoError(t, loadErr)
|
|
require.Len(t, sf.Stacks, 1)
|
|
}
|
|
|
|
func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) {
|
|
// A stack tracked before the number was recorded (Number == 0) is resolved
|
|
// by mapping its internal ID to the remote stack number, and the backfilled
|
|
// number is persisted.
|
|
gitDir := t.TempDir()
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return gitDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b1", nil },
|
|
})
|
|
defer restore()
|
|
|
|
writeStackFile(t, gitDir, stack.Stack{
|
|
ID: "99", // legacy: internal ID present, Number unset (0)
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
|
|
},
|
|
})
|
|
|
|
var unstackedNumber int
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil
|
|
},
|
|
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
|
|
unstackedNumber = n
|
|
// Some PRs remain stacked, so local tracking is kept.
|
|
return &github.RemoteStack{ID: 99, Number: 7, PullRequests: []int{102}}, false, nil
|
|
},
|
|
}
|
|
|
|
err := runUnstack(cfg, &unstackOptions{stackNumber: 7})
|
|
output := collectOutput(cfg, outR, errR)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 7, unstackedNumber, "should resolve the legacy stack and unstack by its remote number")
|
|
assert.Contains(t, output, "remain stacked on GitHub")
|
|
|
|
// The backfilled number is persisted to the stack file.
|
|
sf, loadErr := stack.Load(gitDir)
|
|
require.NoError(t, loadErr)
|
|
require.Len(t, sf.Stacks, 1)
|
|
assert.Equal(t, 7, sf.Stacks[0].Number, "the resolved stack number should be persisted")
|
|
}
|