mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
554 lines
15 KiB
Go
554 lines
15 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 TestTimeAgo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
duration time.Duration
|
|
want string
|
|
}{
|
|
{"seconds", 30 * time.Second, "30 seconds ago"},
|
|
{"one second", 1 * time.Second, "1 second ago"},
|
|
{"minutes", 5 * time.Minute, "5 minutes ago"},
|
|
{"one minute", 1 * time.Minute, "1 minute ago"},
|
|
{"hours", 3 * time.Hour, "3 hours ago"},
|
|
{"one hour", 1 * time.Hour, "1 hour ago"},
|
|
{"days", 2 * 24 * time.Hour, "2 days ago"},
|
|
{"one day", 24 * time.Hour, "1 day ago"},
|
|
{"months", 60 * 24 * time.Hour, "2 months ago"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := timeAgo(time.Now().Add(-tt.duration))
|
|
assert.Equal(t, tt.want, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestViewJSON(t *testing.T) {
|
|
git.SetOps(&git.MockOps{
|
|
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
|
|
return true, nil // all branches are linear
|
|
},
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
stack *stack.Stack
|
|
currentBranch string
|
|
wantTrunk string
|
|
wantBranches int
|
|
wantCurrent string
|
|
}{
|
|
{
|
|
name: "basic stack with PRs",
|
|
stack: &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "feat/01",
|
|
Head: "bbb",
|
|
Base: "aaa",
|
|
PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"},
|
|
},
|
|
{
|
|
Branch: "feat/02",
|
|
Head: "ccc",
|
|
Base: "bbb",
|
|
PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"},
|
|
},
|
|
},
|
|
},
|
|
currentBranch: "feat/02",
|
|
wantTrunk: "main",
|
|
wantBranches: 2,
|
|
wantCurrent: "feat/02",
|
|
},
|
|
{
|
|
name: "stack with merged branch",
|
|
stack: &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "layer-1",
|
|
Head: "bbb",
|
|
Base: "aaa",
|
|
PullRequest: &stack.PullRequestRef{Number: 10, Merged: true},
|
|
},
|
|
{
|
|
Branch: "layer-2",
|
|
Head: "ccc",
|
|
Base: "bbb",
|
|
},
|
|
},
|
|
},
|
|
currentBranch: "layer-2",
|
|
wantTrunk: "main",
|
|
wantBranches: 2,
|
|
wantCurrent: "layer-2",
|
|
},
|
|
{
|
|
name: "empty stack",
|
|
stack: &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{},
|
|
},
|
|
currentBranch: "main",
|
|
wantTrunk: "main",
|
|
wantBranches: 0,
|
|
wantCurrent: "main",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
defer outR.Close()
|
|
|
|
err := viewJSON(cfg, tt.stack, tt.currentBranch)
|
|
require.NoError(t, err)
|
|
cfg.Out.Close()
|
|
|
|
raw, err := io.ReadAll(outR)
|
|
require.NoError(t, err)
|
|
|
|
var got viewJSONOutput
|
|
err = json.Unmarshal(raw, &got)
|
|
require.NoError(t, err, "output should be valid JSON: %s", string(raw))
|
|
|
|
assert.Equal(t, tt.wantTrunk, got.Trunk)
|
|
assert.Equal(t, tt.wantCurrent, got.CurrentBranch)
|
|
assert.Len(t, got.Branches, tt.wantBranches)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestViewJSON_BranchFields(t *testing.T) {
|
|
git.SetOps(&git.MockOps{
|
|
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
|
|
// feat/02 needs rebase
|
|
if descendant == "feat/02" {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
},
|
|
})
|
|
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "feat/01",
|
|
Head: "bbb222",
|
|
Base: "aaa111",
|
|
PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42", Merged: true},
|
|
},
|
|
{
|
|
Branch: "feat/02",
|
|
Head: "ccc333",
|
|
Base: "bbb222",
|
|
PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
defer outR.Close()
|
|
|
|
err := viewJSON(cfg, s, "feat/02")
|
|
require.NoError(t, err)
|
|
cfg.Out.Close()
|
|
|
|
raw, err := io.ReadAll(outR)
|
|
require.NoError(t, err)
|
|
|
|
var got viewJSONOutput
|
|
require.NoError(t, json.Unmarshal(raw, &got))
|
|
|
|
// First branch: merged
|
|
b0 := got.Branches[0]
|
|
assert.Equal(t, "feat/01", b0.Name)
|
|
assert.Equal(t, "bbb222", b0.Head)
|
|
assert.Equal(t, "aaa111", b0.Base)
|
|
assert.False(t, b0.IsCurrent)
|
|
assert.True(t, b0.IsMerged)
|
|
assert.False(t, b0.NeedsRebase, "merged branches should not need rebase")
|
|
require.NotNil(t, b0.PR)
|
|
assert.Equal(t, 42, b0.PR.Number)
|
|
assert.Equal(t, "MERGED", b0.PR.State)
|
|
assert.Equal(t, "https://github.com/o/r/pull/42", b0.PR.URL)
|
|
|
|
// Second branch: current, needs rebase
|
|
b1 := got.Branches[1]
|
|
assert.Equal(t, "feat/02", b1.Name)
|
|
assert.True(t, b1.IsCurrent)
|
|
assert.False(t, b1.IsMerged)
|
|
assert.True(t, b1.NeedsRebase)
|
|
require.NotNil(t, b1.PR)
|
|
assert.Equal(t, 43, b1.PR.Number)
|
|
assert.Equal(t, "OPEN", b1.PR.State)
|
|
}
|
|
|
|
// TestViewShort_ActiveStack verifies that --short output contains all branch
|
|
// names and the trunk for an active stack.
|
|
func TestViewShort_ActiveStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
{Branch: "b2"},
|
|
{Branch: "b3"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b2", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "b1")
|
|
assert.Contains(t, output, "b2")
|
|
assert.Contains(t, output, "b3")
|
|
assert.Contains(t, output, "main")
|
|
}
|
|
|
|
func TestShortPRSuffix_PlainURLFallback(t *testing.T) {
|
|
t.Setenv("GH_STACK_HYPERLINKS", "0")
|
|
|
|
cfg, outR, errR := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
defer outR.Close()
|
|
defer errR.Close()
|
|
|
|
b := stack.BranchRef{
|
|
PullRequest: &stack.PullRequestRef{
|
|
Number: 42,
|
|
URL: "https://github.com/o/r/pull/42",
|
|
},
|
|
}
|
|
|
|
suffix := shortPRSuffix(cfg, b, "", "", "")
|
|
assert.Equal(t, " #42 (https://github.com/o/r/pull/42)", suffix)
|
|
assert.NotContains(t, suffix, "\x1b]8")
|
|
}
|
|
|
|
// TestViewShort_FullyMergedStack verifies that --short output shows merged
|
|
// branches correctly when all branches in the stack are merged.
|
|
func TestViewShort_FullyMergedStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "main", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "b1")
|
|
assert.Contains(t, output, "b2")
|
|
}
|
|
|
|
// TestViewShort_QueuedStack verifies that --short output shows queued
|
|
// branches with a "queued" separator and the ◎ icon.
|
|
func TestViewShort_QueuedStack(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b3", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
// Mock GitHub client to return b1 as queued (MergeQueueEntry set)
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
|
|
switch number {
|
|
case 1:
|
|
return &github.PullRequest{
|
|
Number: 1,
|
|
ID: "PR_1",
|
|
State: "OPEN",
|
|
MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_1"},
|
|
}, nil
|
|
case 2:
|
|
return &github.PullRequest{Number: 2, ID: "PR_2", State: "OPEN"}, nil
|
|
case 3:
|
|
return &github.PullRequest{Number: 3, ID: "PR_3", State: "OPEN"}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "b1")
|
|
assert.Contains(t, output, "b2")
|
|
assert.Contains(t, output, "b3")
|
|
assert.Contains(t, output, "queued", "should show queued separator")
|
|
assert.Contains(t, output, "◎", "should show queued icon for b1")
|
|
}
|
|
|
|
// TestViewShort_MixedQueuedAndMerged verifies that --short output shows
|
|
// both "queued" and "merged" separators in the correct order.
|
|
func TestViewShort_MixedQueuedAndMerged(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "b3", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
|
|
})
|
|
defer restore()
|
|
|
|
// b1 is merged (persisted), b2 is queued (from API)
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
|
|
switch number {
|
|
case 2:
|
|
return &github.PullRequest{
|
|
Number: 2,
|
|
ID: "PR_2",
|
|
State: "OPEN",
|
|
MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_2"},
|
|
}, nil
|
|
case 3:
|
|
return &github.PullRequest{Number: 3, ID: "PR_3", State: "OPEN"}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--short"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
output := string(raw)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "queued", "should show queued separator")
|
|
assert.Contains(t, output, "merged", "should show merged separator")
|
|
|
|
// "merged" section (b1) should appear below "queued" section (b2) in output
|
|
// Since we render top-to-bottom: b3 (active) -> queued separator -> b2 -> merged separator -> b1
|
|
queuedIdx := indexOf(output, "queued")
|
|
mergedIdx := indexOf(output, "merged")
|
|
assert.Less(t, queuedIdx, mergedIdx, "queued separator should appear before merged separator")
|
|
}
|
|
|
|
func indexOf(s, substr string) int {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// writeStackFileMulti writes a stack file with multiple stacks.
|
|
func writeStackFileMulti(t *testing.T, dir string, stacks ...stack.Stack) {
|
|
t.Helper()
|
|
sf := &stack.StackFile{
|
|
SchemaVersion: 1,
|
|
Stacks: stacks,
|
|
}
|
|
data, err := json.MarshalIndent(sf, "", " ")
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644))
|
|
}
|
|
|
|
func TestRunViewJSON_NotInStack(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/01"}},
|
|
})
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "unrelated-branch", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--json"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
|
|
assert.ErrorIs(t, err, ErrNotInStack, "expected exit code 2")
|
|
assert.Contains(t, string(errOut), "not part of a stack")
|
|
}
|
|
|
|
func TestRunViewJSON_MultipleStacks(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
// "main" is the trunk of both stacks → disambiguation.
|
|
writeStackFileMulti(t, tmpDir,
|
|
stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/01"}},
|
|
},
|
|
stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "feat/02"}},
|
|
},
|
|
)
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "main", nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--json"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
|
|
assert.ErrorIs(t, err, ErrDisambiguate, "expected exit code 6")
|
|
assert.Contains(t, string(errOut), "belongs to multiple stacks")
|
|
}
|
|
|
|
func TestRunViewJSON_SingleStack(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
|
|
Branches: []stack.BranchRef{
|
|
{
|
|
Branch: "feat/01",
|
|
Head: "bbb",
|
|
Base: "aaa",
|
|
PullRequest: &stack.PullRequestRef{Number: 10, URL: "https://github.com/o/r/pull/10"},
|
|
},
|
|
},
|
|
})
|
|
|
|
restore := git.SetOps(&git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
|
|
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
|
|
})
|
|
defer restore()
|
|
|
|
cfg, outR, _ := config.NewTestConfig()
|
|
cmd := ViewCmd(cfg)
|
|
cmd.SetArgs([]string{"--json"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Out.Close()
|
|
raw, _ := io.ReadAll(outR)
|
|
|
|
require.NoError(t, err)
|
|
|
|
var got viewJSONOutput
|
|
require.NoError(t, json.Unmarshal(raw, &got), "output should be valid JSON: %s", string(raw))
|
|
assert.Equal(t, "main", got.Trunk)
|
|
assert.Len(t, got.Branches, 1)
|
|
assert.Equal(t, "feat/01", got.Branches[0].Name)
|
|
assert.True(t, got.Branches[0].IsCurrent)
|
|
}
|