Skip to content

Commit 8ade8bc

Browse files
committed
stack init and add
1 parent de5a937 commit 8ade8bc

8 files changed

Lines changed: 784 additions & 45 deletions

File tree

cmd/add.go

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

cmd/init.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/cli/go-gh/v2/pkg/prompter"
8+
"github.com/github/gh-stack/internal/config"
9+
"github.com/github/gh-stack/internal/git"
10+
"github.com/github/gh-stack/internal/stack"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type initOptions struct {
15+
branches []string
16+
base string
17+
adopt bool
18+
}
19+
20+
func InitCmd(cfg *config.Config) *cobra.Command {
21+
opts := &initOptions{}
22+
23+
cmd := &cobra.Command{
24+
Use: "init [branches...]",
25+
Short: "Initialize a new stack",
26+
Long: `Initialize a stack object in the local repo.
27+
28+
Creates an entry in .git/gh-stack to track stack state.
29+
Unless specified, prompts user to create/select branch for first layer of the stack.
30+
Trunk defaults to default branch, unless specified otherwise.`,
31+
Example: ` $ gh stack init
32+
$ gh stack init myBranch
33+
$ gh stack init branch1 branch2 branch3 --adopt
34+
$ gh stack init firstBranch -b integrationBranch`,
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
opts.branches = args
37+
return runInit(cfg, opts)
38+
},
39+
}
40+
41+
cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)")
42+
cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack")
43+
44+
return cmd
45+
}
46+
47+
func runInit(cfg *config.Config, opts *initOptions) error {
48+
gitDir, err := git.GitDir()
49+
if err != nil {
50+
cfg.Errorf("not a git repository")
51+
return nil
52+
}
53+
54+
// Determine trunk branch
55+
trunk := opts.base
56+
if trunk == "" {
57+
trunk, err = git.DefaultBranch()
58+
if err != nil {
59+
cfg.Errorf("unable to determine default branch: %s\nUse -b to specify the trunk branch", err)
60+
return nil
61+
}
62+
}
63+
64+
// Load existing stack file
65+
sf, err := stack.Load(gitDir)
66+
if err != nil {
67+
cfg.Errorf("failed to load stack state: %s", err)
68+
return nil
69+
}
70+
71+
// Set repository context
72+
repo, err := cfg.Repo()
73+
if err == nil {
74+
sf.Repository = repo.Owner + "/" + repo.Name
75+
}
76+
77+
currentBranch, _ := git.CurrentBranch()
78+
79+
var branches []string
80+
81+
if opts.adopt {
82+
// Adopt mode: validate all specified branches exist
83+
if len(opts.branches) == 0 {
84+
cfg.Errorf("--adopt requires at least one branch name")
85+
return nil
86+
}
87+
for _, b := range opts.branches {
88+
if !git.BranchExists(b) {
89+
cfg.Errorf("branch %q does not exist", b)
90+
return nil
91+
}
92+
if err := sf.ValidateNoDuplicateBranch(b); err != nil {
93+
cfg.Errorf("branch %q already exists in the stack", b)
94+
return nil
95+
}
96+
}
97+
branches = opts.branches
98+
} else if len(opts.branches) > 0 {
99+
// Explicit branch names provided — create them
100+
for _, b := range opts.branches {
101+
if err := sf.ValidateNoDuplicateBranch(b); err != nil {
102+
cfg.Errorf("branch %q already exists in the stack", b)
103+
return nil
104+
}
105+
if !git.BranchExists(b) {
106+
if err := git.CreateBranch(b, trunk); err != nil {
107+
cfg.Errorf("creating branch %s: %s", b, err)
108+
return nil
109+
}
110+
}
111+
}
112+
branches = opts.branches
113+
} else {
114+
// Interactive mode
115+
p := prompter.New(os.Stdin, os.Stdout, os.Stderr)
116+
117+
if currentBranch != "" && currentBranch != trunk {
118+
// Already on a non-trunk branch — offer to use it
119+
useCurrentBranch, err := p.Confirm(
120+
fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch),
121+
true,
122+
)
123+
if err != nil {
124+
cfg.Errorf("failed to confirm branch selection: %s", err)
125+
return nil
126+
}
127+
if useCurrentBranch {
128+
if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil {
129+
cfg.Errorf("branch %q already exists in the stack", currentBranch)
130+
return nil
131+
}
132+
branches = []string{currentBranch}
133+
}
134+
}
135+
136+
if len(branches) == 0 {
137+
branchName, err := p.Input("What branch would you like to use as the first layer of your stack?", "")
138+
if err != nil {
139+
cfg.Errorf("failed to read branch name: %s", err)
140+
return nil
141+
}
142+
if branchName == "" {
143+
cfg.Errorf("branch name cannot be empty")
144+
return nil
145+
}
146+
if err := sf.ValidateNoDuplicateBranch(branchName); err != nil {
147+
cfg.Errorf("branch %q already exists in the stack", branchName)
148+
return nil
149+
}
150+
if !git.BranchExists(branchName) {
151+
if err := git.CreateBranch(branchName, trunk); err != nil {
152+
cfg.Errorf("creating branch %s: %s", branchName, err)
153+
return nil
154+
}
155+
}
156+
branches = []string{branchName}
157+
}
158+
}
159+
160+
// Build stack
161+
trunkSHA, _ := git.HeadSHA(trunk)
162+
branchRefs := make([]stack.BranchRef, len(branches))
163+
for i, b := range branches {
164+
sha, _ := git.HeadSHA(b)
165+
branchRefs[i] = stack.BranchRef{Branch: b, Head: sha}
166+
}
167+
168+
newStack := stack.Stack{
169+
Trunk: stack.BranchRef{
170+
Branch: trunk,
171+
Head: trunkSHA,
172+
},
173+
Branches: branchRefs,
174+
}
175+
176+
sf.AddStack(newStack)
177+
if err := stack.Save(gitDir, sf); err != nil {
178+
return err
179+
}
180+
181+
// Print result
182+
if opts.adopt {
183+
cfg.Printf("Adopting stack with trunk %s and %d branches", trunk, len(branches))
184+
chainParts := []string{"(" + trunk + ")"}
185+
for _, b := range branches {
186+
chainParts = append(chainParts, b)
187+
}
188+
cfg.Printf("Initializing stack: %s", joinChain(chainParts))
189+
cfg.Printf("You can continue working on %s", branches[len(branches)-1])
190+
} else {
191+
cfg.Successf("Creating stack with trunk %s and branch %s", trunk, branches[len(branches)-1])
192+
// Switch to last branch if not already there
193+
lastBranch := branches[len(branches)-1]
194+
if currentBranch != lastBranch {
195+
if err := git.CheckoutBranch(lastBranch); err != nil {
196+
cfg.Errorf("switching to branch %s: %s", lastBranch, err)
197+
return nil
198+
}
199+
cfg.Printf("Switched to branch %s", lastBranch)
200+
} else {
201+
cfg.Printf("You can continue working on %s", lastBranch)
202+
}
203+
}
204+
205+
cfg.Printf("To add a new layer to your stack, run %s", cfg.ColorCyan("gh stack add"))
206+
cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run %s", cfg.ColorCyan("gh stack push"))
207+
208+
return nil
209+
}
210+
211+
// joinChain formats branches as: (trunk) <- branch1 <- branch2
212+
func joinChain(parts []string) string {
213+
result := parts[0]
214+
for _, p := range parts[1:] {
215+
result += " <- " + p
216+
}
217+
return result
218+
}

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ func RootCmd() *cobra.Command {
2121
root.SetOut(cfg.Out)
2222
root.SetErr(cfg.Err)
2323

24+
root.AddCommand(InitCmd(cfg))
25+
root.AddCommand(AddCmd(cfg))
26+
2427
for _, ph := range placeholderCommands {
2528
root.AddCommand(PlaceholderCmd(ph, cfg))
2629
}

go.mod

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,39 @@ module github.com/github/gh-stack
22

33
go 1.25.7
44

5-
require github.com/cli/go-gh/v2 v2.13.0
5+
require (
6+
github.com/cli/cli/v2 v2.86.0
7+
github.com/cli/go-gh/v2 v2.13.0
8+
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
9+
github.com/spf13/cobra v1.10.2
10+
)
611

712
require (
13+
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
814
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
9-
github.com/cli/safeexec v1.0.0 // indirect
10-
github.com/cli/shurcooL-graphql v0.0.4 // indirect
11-
github.com/henvic/httpretty v0.0.6 // indirect
15+
github.com/charmbracelet/colorprofile v0.3.1 // indirect
16+
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
17+
github.com/charmbracelet/x/ansi v0.10.2 // indirect
18+
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
19+
github.com/charmbracelet/x/term v0.2.1 // indirect
20+
github.com/cli/safeexec v1.0.1 // indirect
21+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
1222
github.com/inconshreveable/mousetrap v1.1.0 // indirect
23+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
1324
github.com/kr/pretty v0.3.1 // indirect
14-
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
15-
github.com/mattn/go-colorable v0.1.13 // indirect
25+
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
26+
github.com/mattn/go-colorable v0.1.14 // indirect
1627
github.com/mattn/go-isatty v0.0.20 // indirect
17-
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
28+
github.com/mattn/go-runewidth v0.0.17 // indirect
29+
github.com/muesli/reflow v0.3.0 // indirect
1830
github.com/muesli/termenv v0.16.0 // indirect
31+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
1932
github.com/rivo/uniseg v0.4.7 // indirect
20-
github.com/spf13/cobra v1.10.2 // indirect
21-
github.com/spf13/pflag v1.0.9 // indirect
22-
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
23-
golang.org/x/sys v0.31.0 // indirect
24-
golang.org/x/term v0.30.0 // indirect
25-
golang.org/x/text v0.23.0 // indirect
33+
github.com/spf13/pflag v1.0.10 // indirect
34+
github.com/stretchr/testify v1.11.1 // indirect
35+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
36+
golang.org/x/sys v0.39.0 // indirect
37+
golang.org/x/term v0.38.0 // indirect
38+
golang.org/x/text v0.32.0 // indirect
2639
gopkg.in/yaml.v3 v3.0.1 // indirect
2740
)

0 commit comments

Comments
 (0)