Skip to content

Commit 14fee97

Browse files
committed
first pass at commands and scaffolding
1 parent c6da4fc commit 14fee97

21 files changed

Lines changed: 3772 additions & 20 deletions

cmd/add.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/githubnext/gh-stack/internal/config"
7+
"github.com/githubnext/gh-stack/internal/git"
8+
"github.com/githubnext/gh-stack/internal/stack"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func NewAddCmd(cfg *config.Config) *cobra.Command {
13+
cmd := &cobra.Command{
14+
Use: "add [branch]",
15+
Short: "Add a new branch on top of the current stack",
16+
Args: cobra.MaximumNArgs(1),
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
return runAdd(cfg, args)
19+
},
20+
}
21+
return cmd
22+
}
23+
24+
func runAdd(cfg *config.Config, args []string) error {
25+
gitDir, err := git.GitDir()
26+
if err != nil {
27+
return fmt.Errorf("not a git repository")
28+
}
29+
30+
sf, err := stack.Load(gitDir)
31+
if err != nil {
32+
return err
33+
}
34+
35+
currentBranch, err := git.CurrentBranch()
36+
if err != nil {
37+
return err
38+
}
39+
40+
s := sf.FindStackForBranch(currentBranch)
41+
if s == nil {
42+
return fmt.Errorf("current branch %q is not part of a stack; run 'gh stack init' first", currentBranch)
43+
}
44+
45+
idx := s.IndexOf(currentBranch)
46+
if idx >= 0 && idx < len(s.Branches)-1 {
47+
return fmt.Errorf("can only add branches on top of the stack; checkout the top branch %q first", s.Branches[len(s.Branches)-1].Branch)
48+
}
49+
50+
var branchName string
51+
if len(args) > 0 {
52+
branchName = args[0]
53+
} else {
54+
fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ")
55+
if _, err := fmt.Fscan(cfg.In, &branchName); err != nil {
56+
return fmt.Errorf("could not read branch name: %w", err)
57+
}
58+
}
59+
60+
if branchName == "" {
61+
return fmt.Errorf("branch name cannot be empty")
62+
}
63+
64+
if err := sf.ValidateNoDuplicateBranch(branchName); err != nil {
65+
return err
66+
}
67+
68+
if git.BranchExists(branchName) {
69+
return fmt.Errorf("branch %q already exists", branchName)
70+
}
71+
72+
if err := git.CreateBranch(branchName, currentBranch); err != nil {
73+
return fmt.Errorf("failed to create branch: %w", err)
74+
}
75+
76+
if err := git.CheckoutBranch(branchName); err != nil {
77+
return fmt.Errorf("failed to checkout branch: %w", err)
78+
}
79+
80+
head, _ := git.HeadSHA(branchName)
81+
s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Head: head})
82+
83+
if err := stack.Save(gitDir, sf); err != nil {
84+
return err
85+
}
86+
87+
cfg.Successf("Created and checked out branch %q\n", branchName)
88+
return nil
89+
}

cmd/checkout.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/githubnext/gh-stack/internal/config"
9+
"github.com/githubnext/gh-stack/internal/git"
10+
"github.com/githubnext/gh-stack/internal/stack"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type checkoutOptions struct {
15+
target string
16+
noSwitch bool
17+
}
18+
19+
func NewCheckoutCmd(cfg *config.Config) *cobra.Command {
20+
opts := &checkoutOptions{}
21+
22+
cmd := &cobra.Command{
23+
Use: "checkout <pr-or-branch>",
24+
Short: "Checkout a stack from a PR number or branch name",
25+
Long: "Discover and check out an entire stack from a pull request number, URL, or branch name.",
26+
Args: cobra.ExactArgs(1),
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
opts.target = args[0]
29+
return runCheckout(cfg, opts)
30+
},
31+
}
32+
33+
cmd.Flags().BoolVar(&opts.noSwitch, "no-switch", false, "Fetch and track the stack without switching branches")
34+
35+
return cmd
36+
}
37+
38+
func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
39+
gitDir, err := git.GitDir()
40+
if err != nil {
41+
return fmt.Errorf("not a git repository")
42+
}
43+
44+
client, err := cfg.GitHubClient()
45+
if err != nil {
46+
return err
47+
}
48+
49+
repo, err := cfg.Repo()
50+
if err != nil {
51+
return err
52+
}
53+
54+
owner := repo.Owner
55+
name := repo.Name
56+
57+
// Resolve target to a branch name
58+
var headBranch string
59+
prNum, parseErr := parsePRTarget(opts.target)
60+
if parseErr == nil && prNum > 0 {
61+
pr, prErr := client.GetPR(prNum)
62+
if prErr != nil {
63+
return fmt.Errorf("failed to fetch PR #%d: %w", prNum, prErr)
64+
}
65+
headBranch = pr.HeadRefName
66+
} else {
67+
headBranch = opts.target
68+
}
69+
70+
// Discover the stack via PR chain: first find the PR for this branch
71+
cfg.Printf("Discovering stack from branch %q...\n", headBranch)
72+
startPR, err := client.FindPRForBranch(headBranch)
73+
if err != nil {
74+
return fmt.Errorf("failed to find PR for branch %q: %w", headBranch, err)
75+
}
76+
if startPR == nil {
77+
return fmt.Errorf("no pull request found for branch %q", headBranch)
78+
}
79+
80+
prs, trunk, err := client.FindStackPRs(startPR)
81+
if err != nil {
82+
return fmt.Errorf("failed to discover stack: %w", err)
83+
}
84+
85+
if len(prs) == 0 {
86+
return fmt.Errorf("no pull requests found for branch %q", headBranch)
87+
}
88+
89+
// Fetch all branches
90+
cfg.Printf("Fetching %d branches...\n", len(prs))
91+
if err := git.Fetch("origin"); err != nil {
92+
cfg.Warningf("fetch failed: %v\n", err)
93+
}
94+
95+
var branches []stack.BranchRef
96+
for _, pr := range prs {
97+
branchName := pr.HeadRefName
98+
99+
if !git.BranchExists(branchName) {
100+
// Create local branch tracking the remote
101+
if err := git.CreateBranch(branchName, "origin/"+branchName); err != nil {
102+
cfg.Warningf("failed to create branch %q: %v\n", branchName, err)
103+
continue
104+
}
105+
}
106+
107+
if err := git.SetUpstreamTracking(branchName, "origin/"+branchName); err != nil {
108+
cfg.Warningf("failed to set upstream for %q: %v\n", branchName, err)
109+
}
110+
111+
head, _ := git.HeadSHA(branchName)
112+
branches = append(branches, stack.BranchRef{
113+
Branch: branchName,
114+
Head: head,
115+
})
116+
}
117+
118+
// Save to local tracking
119+
sf, err := stack.Load(gitDir)
120+
if err != nil {
121+
return err
122+
}
123+
124+
trunkHead, _ := git.HeadSHA(trunk)
125+
newStack := stack.Stack{
126+
Trunk: stack.BranchRef{Branch: trunk, Head: trunkHead},
127+
Branches: branches,
128+
}
129+
130+
sf.AddStack(newStack)
131+
132+
repoStr := fmt.Sprintf("%s/%s", owner, name)
133+
sf.Repository = repoStr
134+
135+
if err := stack.Save(gitDir, sf); err != nil {
136+
return err
137+
}
138+
139+
// Switch to the target branch
140+
if !opts.noSwitch {
141+
if err := git.CheckoutBranch(headBranch); err != nil {
142+
cfg.Warningf("failed to checkout %q: %v\n", headBranch, err)
143+
}
144+
}
145+
146+
cfg.Successf("Checked out stack with %d branches (trunk: %s)\n", len(branches), trunk)
147+
for i, b := range branches {
148+
marker := " "
149+
if b.Branch == headBranch {
150+
marker = "* "
151+
}
152+
cfg.Outf("%s%d. %s\n", marker, i+1, b.Branch)
153+
}
154+
155+
return nil
156+
}
157+
158+
func parsePRTarget(target string) (int, error) {
159+
// Handle #123 format
160+
target = strings.TrimPrefix(target, "#")
161+
162+
// Handle URL format
163+
if strings.Contains(target, "/pull/") {
164+
parts := strings.Split(target, "/pull/")
165+
if len(parts) == 2 {
166+
target = strings.TrimSuffix(parts[1], "/")
167+
}
168+
}
169+
170+
return strconv.Atoi(target)
171+
}

0 commit comments

Comments
 (0)