From de5a93709c154ddd5eb448d514094dd912d94aad Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Feb 2026 00:37:11 -0500 Subject: [PATCH 01/78] basic terminal setup --- cmd/placeholder.go | 35 +++++++++++++++++ cmd/root.go | 36 +++++++++++++++++ go.mod | 7 +++- go.sum | 15 +++++++ internal/config/config.go | 83 +++++++++++++++++++++++++++++++++++++++ main.go | 21 +--------- 6 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 cmd/placeholder.go create mode 100644 cmd/root.go create mode 100644 internal/config/config.go diff --git a/cmd/placeholder.go b/cmd/placeholder.go new file mode 100644 index 0000000..5715640 --- /dev/null +++ b/cmd/placeholder.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +type placeholderDef struct { + Name string + Short string +} + +var placeholderCommands = []placeholderDef{ + {"remove", "Remove a branch from a stack"}, + {"modify", "Modify a branch in a stack"}, + {"reorder", "Reorder branches in a stack"}, + {"move", "Move a branch between stacks"}, + {"fold", "Fold a branch into the branch below it"}, + {"squash", "Squash commits in a branch"}, + {"rename", "Rename a branch in a stack"}, + {"split", "Split a branch into two branches"}, +} + +func PlaceholderCmd(def placeholderDef, cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: def.Name, + Short: def.Short, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg.Warningf("`gh stack %s` is not yet supported.", def.Name) + cfg.Infof("Run `gh stack feedback` to share your thoughts on this feature.") + return nil + }, + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8e6024f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "os" + + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +func RootCmd() *cobra.Command { + cfg := config.New() + + root := &cobra.Command{ + Use: "stack ", + Short: "Manage stacked branches and pull requests", + Long: "Create, navigate, and manage stacks of branches and pull requests.", + SilenceUsage: true, + SilenceErrors: true, + } + + root.SetOut(cfg.Out) + root.SetErr(cfg.Err) + + for _, ph := range placeholderCommands { + root.AddCommand(PlaceholderCmd(ph, cfg)) + } + + return root +} + +func Execute() { + cmd := RootCmd() + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 81e9f07..6b5994d 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/githubnext/gh-stack +module github.com/github/gh-stack go 1.25.7 @@ -9,11 +9,16 @@ require ( github.com/cli/safeexec v1.0.0 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/henvic/httpretty v0.0.6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/term v0.30.0 // indirect diff --git a/go.sum b/go.sum index ea8ce03..584f72e 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,14 +14,21 @@ github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslC github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -30,11 +38,18 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7708e5f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,83 @@ +package config + +import ( + "fmt" + "io" + "os" + + "github.com/cli/go-gh/v2/pkg/term" + "github.com/mgutz/ansi" +) + +// Config holds shared state for all commands +type Config struct { + Terminal term.Term + Out io.Writer + Err io.Writer + In io.Reader + + ColorSuccess func(string) string + ColorError func(string) string + ColorWarning func(string) string + ColorBold func(string) string + ColorCyan func(string) string + ColorGray func(string) string +} + +// New creates a new Config with terminal-aware output and color support +func New() *Config { + terminal := term.FromEnv() + cfg := &Config{ + Terminal: terminal, + Out: terminal.Out(), + Err: terminal.ErrOut(), + In: os.Stdin, + } + + if terminal.IsColorEnabled() { + cfg.ColorSuccess = ansi.ColorFunc("green") + cfg.ColorError = ansi.ColorFunc("red") + cfg.ColorWarning = ansi.ColorFunc("yellow") + cfg.ColorBold = ansi.ColorFunc("default+b") + cfg.ColorCyan = ansi.ColorFunc("cyan") + cfg.ColorGray = ansi.ColorFunc("white+d") + } else { + noop := func(s string) string { return s } + cfg.ColorSuccess = noop + cfg.ColorError = noop + cfg.ColorWarning = noop + cfg.ColorBold = noop + cfg.ColorCyan = noop + cfg.ColorGray = noop + } + + return cfg +} + +func (c *Config) Successf(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorSuccess("\u2713"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Errorf(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorError("\u2717"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Warningf(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorWarning("\u26a0"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Infof(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorCyan("\u2139"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Printf(format string, args ...any) { + fmt.Fprintf(c.Err, format+"\n", args...) +} + +func (c *Config) Outf(format string, args ...any) { + fmt.Fprintf(c.Out, format, args...) +} + +func (c *Config) IsInteractive() bool { + return c.Terminal.IsTerminalOutput() +} diff --git a/main.go b/main.go index 5f07f5e..8e37389 100644 --- a/main.go +++ b/main.go @@ -1,26 +1,9 @@ package main import ( - "fmt" - - "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-stack/cmd" ) func main() { - fmt.Println("hi world, this is the gh-stack extension!") - client, err := api.DefaultRESTClient() - if err != nil { - fmt.Println(err) - return - } - response := struct {Login string}{} - err = client.Get("user", &response) - if err != nil { - fmt.Println(err) - return - } - fmt.Printf("running as %s\n", response.Login) + cmd.Execute() } - -// For more examples of using go-gh, see: -// https://github.com/cli/go-gh/blob/trunk/example_gh_test.go From 8ade8bc5217c5352148ed6f4eaf9022134f7f93b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Feb 2026 21:44:51 -0500 Subject: [PATCH 02/78] stack init and add --- cmd/add.go | 100 +++++++++++++++++ cmd/init.go | 218 ++++++++++++++++++++++++++++++++++++++ cmd/root.go | 3 + go.mod | 39 ++++--- go.sum | 118 +++++++++++++++------ internal/config/config.go | 9 +- internal/git/git.go | 191 +++++++++++++++++++++++++++++++++ internal/stack/stack.go | 151 ++++++++++++++++++++++++++ 8 files changed, 784 insertions(+), 45 deletions(-) create mode 100644 cmd/add.go create mode 100644 cmd/init.go create mode 100644 internal/git/git.go create mode 100644 internal/stack/stack.go diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..75dc699 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +func AddCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "add [branch]", + Short: "Add a new branch on top of the current stack", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAdd(cfg, args) + }, + } + return cmd +} + +func runAdd(cfg *config.Config, args []string) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + + s := sf.FindStackForBranch(currentBranch) + if s == nil { + cfg.Errorf("current branch %q is not part of a stack; run 'gh stack init' first", currentBranch) + return nil + } + + idx := s.IndexOf(currentBranch) + if idx >= 0 && idx < len(s.Branches)-1 { + cfg.Errorf("can only add branches on top of the stack; checkout the top branch %q first", s.Branches[len(s.Branches)-1].Branch) + return nil + } + + var branchName string + if len(args) > 0 { + branchName = args[0] + } else { + fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ") + if _, err := fmt.Fscan(cfg.In, &branchName); err != nil { + return fmt.Errorf("could not read branch name: %w", err) + } + } + + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return nil + } + + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in the stack", branchName) + return nil + } + + if git.BranchExists(branchName) { + cfg.Errorf("branch %q already exists", branchName) + return nil + } + + if err := git.CreateBranch(branchName, currentBranch); err != nil { + cfg.Errorf("failed to create branch: %s", err) + return nil + } + + if err := git.CheckoutBranch(branchName); err != nil { + cfg.Errorf("failed to checkout branch: %s", err) + return nil + } + + head, _ := git.HeadSHA(branchName) + s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Head: head}) + + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return nil + } + + cfg.Successf("Created and checked out branch %q\n", branchName) + return nil +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..68addb6 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type initOptions struct { + branches []string + base string + adopt bool +} + +func InitCmd(cfg *config.Config) *cobra.Command { + opts := &initOptions{} + + cmd := &cobra.Command{ + Use: "init [branches...]", + Short: "Initialize a new stack", + Long: `Initialize a stack object in the local repo. + +Creates an entry in .git/gh-stack to track stack state. +Unless specified, prompts user to create/select branch for first layer of the stack. +Trunk defaults to default branch, unless specified otherwise.`, + Example: ` $ gh stack init + $ gh stack init myBranch + $ gh stack init branch1 branch2 branch3 --adopt + $ gh stack init firstBranch -b integrationBranch`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.branches = args + return runInit(cfg, opts) + }, + } + + cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") + cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack") + + return cmd +} + +func runInit(cfg *config.Config, opts *initOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + // Determine trunk branch + trunk := opts.base + if trunk == "" { + trunk, err = git.DefaultBranch() + if err != nil { + cfg.Errorf("unable to determine default branch: %s\nUse -b to specify the trunk branch", err) + return nil + } + } + + // Load existing stack file + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + // Set repository context + repo, err := cfg.Repo() + if err == nil { + sf.Repository = repo.Owner + "/" + repo.Name + } + + currentBranch, _ := git.CurrentBranch() + + var branches []string + + if opts.adopt { + // Adopt mode: validate all specified branches exist + if len(opts.branches) == 0 { + cfg.Errorf("--adopt requires at least one branch name") + return nil + } + for _, b := range opts.branches { + if !git.BranchExists(b) { + cfg.Errorf("branch %q does not exist", b) + return nil + } + if err := sf.ValidateNoDuplicateBranch(b); err != nil { + cfg.Errorf("branch %q already exists in the stack", b) + return nil + } + } + branches = opts.branches + } else if len(opts.branches) > 0 { + // Explicit branch names provided — create them + for _, b := range opts.branches { + if err := sf.ValidateNoDuplicateBranch(b); err != nil { + cfg.Errorf("branch %q already exists in the stack", b) + return nil + } + if !git.BranchExists(b) { + if err := git.CreateBranch(b, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", b, err) + return nil + } + } + } + branches = opts.branches + } else { + // Interactive mode + p := prompter.New(os.Stdin, os.Stdout, os.Stderr) + + if currentBranch != "" && currentBranch != trunk { + // Already on a non-trunk branch — offer to use it + useCurrentBranch, err := p.Confirm( + fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch), + true, + ) + if err != nil { + cfg.Errorf("failed to confirm branch selection: %s", err) + return nil + } + if useCurrentBranch { + if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { + cfg.Errorf("branch %q already exists in the stack", currentBranch) + return nil + } + branches = []string{currentBranch} + } + } + + if len(branches) == 0 { + branchName, err := p.Input("What branch would you like to use as the first layer of your stack?", "") + if err != nil { + cfg.Errorf("failed to read branch name: %s", err) + return nil + } + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return nil + } + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in the stack", branchName) + return nil + } + if !git.BranchExists(branchName) { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return nil + } + } + branches = []string{branchName} + } + } + + // Build stack + trunkSHA, _ := git.HeadSHA(trunk) + branchRefs := make([]stack.BranchRef, len(branches)) + for i, b := range branches { + sha, _ := git.HeadSHA(b) + branchRefs[i] = stack.BranchRef{Branch: b, Head: sha} + } + + newStack := stack.Stack{ + Trunk: stack.BranchRef{ + Branch: trunk, + Head: trunkSHA, + }, + Branches: branchRefs, + } + + sf.AddStack(newStack) + if err := stack.Save(gitDir, sf); err != nil { + return err + } + + // Print result + if opts.adopt { + cfg.Printf("Adopting stack with trunk %s and %d branches", trunk, len(branches)) + chainParts := []string{"(" + trunk + ")"} + for _, b := range branches { + chainParts = append(chainParts, b) + } + cfg.Printf("Initializing stack: %s", joinChain(chainParts)) + cfg.Printf("You can continue working on %s", branches[len(branches)-1]) + } else { + cfg.Successf("Creating stack with trunk %s and branch %s", trunk, branches[len(branches)-1]) + // Switch to last branch if not already there + lastBranch := branches[len(branches)-1] + if currentBranch != lastBranch { + if err := git.CheckoutBranch(lastBranch); err != nil { + cfg.Errorf("switching to branch %s: %s", lastBranch, err) + return nil + } + cfg.Printf("Switched to branch %s", lastBranch) + } else { + cfg.Printf("You can continue working on %s", lastBranch) + } + } + + cfg.Printf("To add a new layer to your stack, run %s", cfg.ColorCyan("gh stack add")) + cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run %s", cfg.ColorCyan("gh stack push")) + + return nil +} + +// joinChain formats branches as: (trunk) <- branch1 <- branch2 +func joinChain(parts []string) string { + result := parts[0] + for _, p := range parts[1:] { + result += " <- " + p + } + return result +} diff --git a/cmd/root.go b/cmd/root.go index 8e6024f..f9e8257 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,9 @@ func RootCmd() *cobra.Command { root.SetOut(cfg.Out) root.SetErr(cfg.Err) + root.AddCommand(InitCmd(cfg)) + root.AddCommand(AddCmd(cfg)) + for _, ph := range placeholderCommands { root.AddCommand(PlaceholderCmd(ph, cfg)) } diff --git a/go.mod b/go.mod index 6b5994d..5df02d9 100644 --- a/go.mod +++ b/go.mod @@ -2,26 +2,39 @@ module github.com/github/gh-stack go 1.25.7 -require github.com/cli/go-gh/v2 v2.13.0 +require ( + github.com/cli/cli/v2 v2.86.0 + github.com/cli/go-gh/v2 v2.13.0 + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d + github.com/spf13/cobra v1.10.2 +) require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/cli/safeexec v1.0.0 // indirect - github.com/cli/shurcooL-graphql v0.0.4 // indirect - github.com/henvic/httpretty v0.0.6 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 584f72e..d909298 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,70 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/cli/v2 v2.86.0 h1:114DaPhDvKNMp8MTLffN119mHe040eNhNgLv3qi3mNA= +github.com/cli/cli/v2 v2.86.0/go.mod h1:cMrBHQOYc0MdNBseT5pUT6uxhvz4gcf010FEO7bWsP8= github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys= github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM= -github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= -github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= -github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= +github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= -github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -41,26 +72,53 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= -github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= -gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 7708e5f..c48f8f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,11 +5,12 @@ import ( "io" "os" + "github.com/cli/go-gh/v2/pkg/repository" "github.com/cli/go-gh/v2/pkg/term" "github.com/mgutz/ansi" ) -// Config holds shared state for all commands +// Config holds shared state for all commands. type Config struct { Terminal term.Term Out io.Writer @@ -24,7 +25,7 @@ type Config struct { ColorGray func(string) string } -// New creates a new Config with terminal-aware output and color support +// New creates a new Config with terminal-aware output and color support. func New() *Config { terminal := term.FromEnv() cfg := &Config{ @@ -81,3 +82,7 @@ func (c *Config) Outf(format string, args ...any) { func (c *Config) IsInteractive() bool { return c.Terminal.IsTerminalOutput() } + +func (c *Config) Repo() (repository.Repository, error) { + return repository.Current() +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..54acbb7 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,191 @@ +package git + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + "time" + + cligit "github.com/cli/cli/v2/git" +) + +// client is a shared git client used by all package-level functions. +var client = &cligit.Client{} + +// CommitInfo holds metadata about a single commit. +type CommitInfo struct { + SHA string + Subject string + Time time.Time +} + +// run executes an arbitrary git command via the client and returns trimmed stdout. +func run(args ...string) (string, error) { + cmd, err := client.Command(context.Background(), args...) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// runSilent executes a git command via the client and only returns an error. +func runSilent(args ...string) error { + cmd, err := client.Command(context.Background(), args...) + if err != nil { + return err + } + return cmd.Run() +} + +// --- Delegated to cligit.Client --- + +// GitDir returns the path to the .git directory. +func GitDir() (string, error) { + return client.GitDir(context.Background()) +} + +// CurrentBranch returns the name of the current branch. +func CurrentBranch() (string, error) { + return client.CurrentBranch(context.Background()) +} + +// BranchExists returns whether a local branch with the given name exists. +func BranchExists(name string) bool { + return client.HasLocalBranch(context.Background(), name) +} + +// CheckoutBranch switches to the specified branch. +func CheckoutBranch(name string) error { + return client.CheckoutBranch(context.Background(), name) +} + +// Fetch fetches from the given remote. +func Fetch(remote string) error { + return client.Fetch(context.Background(), remote, "") +} + +// --- Custom operations not available in cligit --- + +// DefaultBranch returns the default branch of origin. +func DefaultBranch() (string, error) { + ref, err := run("symbolic-ref", "refs/remotes/origin/HEAD") + if err != nil { + for _, name := range []string{"main", "master"} { + if BranchExists(name) { + return name, nil + } + } + return "", fmt.Errorf("unable to determine default branch: %w", err) + } + return strings.TrimPrefix(ref, "refs/remotes/origin/"), nil +} + +// CreateBranch creates a new branch from the given base. +func CreateBranch(name, base string) error { + return runSilent("branch", name, base) +} + +// Push pushes branches to a remote with optional force and atomic flags. +func Push(remote string, branches []string, force, atomic bool) error { + args := []string{"push", remote} + if force { + args = append(args, "--force-with-lease") + } + if atomic { + args = append(args, "--atomic") + } + args = append(args, branches...) + return runSilent(args...) +} + +// Rebase rebases the current branch onto the given base. +func Rebase(onto string) error { + return runSilent("rebase", onto) +} + +// RebaseContinue continues an in-progress rebase. +func RebaseContinue() error { + return runSilent("rebase", "--continue") +} + +// RebaseAbort aborts an in-progress rebase. +func RebaseAbort() error { + return runSilent("rebase", "--abort") +} + +// IsRebaseInProgress checks whether a rebase is currently in progress. +func IsRebaseInProgress() bool { + gitDir, err := GitDir() + if err != nil { + return false + } + for _, dir := range []string{"rebase-merge", "rebase-apply"} { + cmd := exec.Command("test", "-d", gitDir+"/"+dir) + if cmd.Run() == nil { + return true + } + } + return false +} + +// HeadSHA returns the full SHA of the given ref. +func HeadSHA(ref string) (string, error) { + return run("rev-parse", ref) +} + +// Log returns recent commits for the given branch. +func Log(ref string, maxCount int) ([]CommitInfo, error) { + format := "%H\t%s\t%at" + output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(parts[2], 10, 64) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: parts[1], + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} + +// DeleteBranch deletes a local branch. +func DeleteBranch(name string, force bool) error { + flag := "-d" + if force { + flag = "-D" + } + return runSilent("branch", flag, name) +} + +// DeleteRemoteBranch deletes a branch on the remote. +func DeleteRemoteBranch(remote, branch string) error { + return runSilent("push", remote, "--delete", branch) +} + +// ResetHard resets the current branch to the given ref. +func ResetHard(ref string) error { + return runSilent("reset", "--hard", ref) +} + +// SetUpstreamTracking sets the upstream tracking branch. +func SetUpstreamTracking(branch, remote string) error { + return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go new file mode 100644 index 0000000..e320d32 --- /dev/null +++ b/internal/stack/stack.go @@ -0,0 +1,151 @@ +package stack + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + schemaVersion = 1 + stackFileName = "gh-stack" +) + +// BranchRef represents a branch and its HEAD commit. +type BranchRef struct { + Branch string `json:"branch"` + Head string `json:"head"` +} + +// Stack represents a single stack of branches. +type Stack struct { + Trunk BranchRef `json:"trunk"` + Branches []BranchRef `json:"branches"` +} + +// BranchNames returns the list of branch names in order. +func (s *Stack) BranchNames() []string { + names := make([]string, len(s.Branches)) + for i, b := range s.Branches { + names[i] = b.Branch + } + return names +} + +// IndexOf returns the index of the given branch in the stack, or -1 if not found. +func (s *Stack) IndexOf(branch string) int { + for i, b := range s.Branches { + if b.Branch == branch { + return i + } + } + return -1 +} + +// Contains returns true if the branch is part of this stack (including trunk). +func (s *Stack) Contains(branch string) bool { + if s.Trunk.Branch == branch { + return true + } + return s.IndexOf(branch) >= 0 +} + +// BaseBranch returns the base branch for the given branch in the stack. +// For the first branch, this is the trunk. For others, it's the previous branch. +func (s *Stack) BaseBranch(branch string) string { + idx := s.IndexOf(branch) + if idx <= 0 { + return s.Trunk.Branch + } + return s.Branches[idx-1].Branch +} + +// StackFile represents the JSON file stored in .git/gh-stack. +type StackFile struct { + SchemaVersion int `json:"schemaVersion"` + Repository string `json:"repository"` + Stacks []Stack `json:"stacks"` +} + +// FindStackForBranch returns the stack that contains the given branch, or nil. +func (sf *StackFile) FindStackForBranch(branch string) *Stack { + for i := range sf.Stacks { + if sf.Stacks[i].Contains(branch) { + return &sf.Stacks[i] + } + } + return nil +} + +// ValidateNoDuplicateBranch checks that the branch is not already in any stack. +func (sf *StackFile) ValidateNoDuplicateBranch(branch string) error { + for _, s := range sf.Stacks { + if s.Contains(branch) { + return fmt.Errorf("branch %q is already part of a stack", branch) + } + } + return nil +} + +// AddStack adds a new stack to the file. +func (sf *StackFile) AddStack(s Stack) { + sf.Stacks = append(sf.Stacks, s) +} + +// RemoveStack removes the stack at the given index. +func (sf *StackFile) RemoveStack(idx int) { + sf.Stacks = append(sf.Stacks[:idx], sf.Stacks[idx+1:]...) +} + +// RemoveStackForBranch removes the stack containing the given branch. +func (sf *StackFile) RemoveStackForBranch(branch string) bool { + for i := range sf.Stacks { + if sf.Stacks[i].Contains(branch) { + sf.RemoveStack(i) + return true + } + } + return false +} + +// stackFilePath returns the path to the gh-stack file. +func stackFilePath(gitDir string) string { + return filepath.Join(gitDir, stackFileName) +} + +// Load reads the stack file from the given git directory. +// Returns an empty StackFile if the file does not exist. +func Load(gitDir string) (*StackFile, error) { + path := stackFilePath(gitDir) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &StackFile{ + SchemaVersion: schemaVersion, + }, nil + } + return nil, fmt.Errorf("reading stack file: %w", err) + } + + var sf StackFile + if err := json.Unmarshal(data, &sf); err != nil { + return nil, fmt.Errorf("parsing stack file: %w", err) + } + return &sf, nil +} + +// Save writes the stack file to the given git directory. +func Save(gitDir string, sf *StackFile) error { + sf.SchemaVersion = schemaVersion + data, err := json.MarshalIndent(sf, "", " ") + if err != nil { + return fmt.Errorf("marshaling stack file: %w", err) + } + path := stackFilePath(gitDir) + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing stack file: %w", err) + } + return nil +} From 2536e9a5d4c394e4344cc5d3487b9f6e6ebe80bf Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 17 Feb 2026 19:35:26 -0500 Subject: [PATCH 03/78] view stack --- cmd/root.go | 5 + cmd/view.go | 251 ++++++++++++++++++++++++++++++++++++++ go.mod | 5 + go.sum | 17 +++ internal/config/config.go | 18 ++- internal/github/github.go | 86 +++++++++++++ 6 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 cmd/view.go create mode 100644 internal/github/github.go diff --git a/cmd/root.go b/cmd/root.go index f9e8257..416d2f2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,9 +21,14 @@ func RootCmd() *cobra.Command { root.SetOut(cfg.Out) root.SetErr(cfg.Err) + // Local operations root.AddCommand(InitCmd(cfg)) root.AddCommand(AddCmd(cfg)) + // Helper commands + root.AddCommand(ViewCmd(cfg)) + + // Placeholders for upcoming features for _, ph := range placeholderCommands { root.AddCommand(PlaceholderCmd(ph, cfg)) } diff --git a/cmd/view.go b/cmd/view.go new file mode 100644 index 0000000..453f8ef --- /dev/null +++ b/cmd/view.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/cli/go-gh/v2/pkg/browser" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type viewOptions struct { + short bool + web bool +} + +func ViewCmd(cfg *config.Config) *cobra.Command { + opts := &viewOptions{} + + cmd := &cobra.Command{ + Use: "view", + Short: "View the current stack", + RunE: func(cmd *cobra.Command, args []string) error { + return runView(cfg, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.short, "short", "s", false, "Show compact output") + cmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open PRs in the browser") + + return cmd +} + +func runView(cfg *config.Config, opts *viewOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + + s := sf.FindStackForBranch(currentBranch) + if s == nil { + cfg.Errorf("current branch %q is not part of a stack", currentBranch) + cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) + return nil + } + + if opts.web { + return viewWeb(cfg, s) + } + + if opts.short { + return viewShort(cfg, s, currentBranch) + } + + return viewFull(cfg, s, currentBranch) +} + +func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { + for i := len(s.Branches) - 1; i >= 0; i-- { + b := s.Branches[i] + if b.Branch == currentBranch { + cfg.Outf("● %s %s\n", cfg.ColorBold(b.Branch), cfg.ColorCyan("(current)")) + } else { + cfg.Outf("○ %s\n", b.Branch) + } + } + cfg.Outf("└ %s\n", s.Trunk.Branch) + return nil +} + +func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { + client, clientErr := cfg.GitHubClient() + + var repoOwner, repoName string + repo, repoErr := cfg.Repo() + if repoErr == nil { + repoOwner = repo.Owner + repoName = repo.Name + } + + var buf bytes.Buffer + + for i := len(s.Branches) - 1; i >= 0; i-- { + b := s.Branches[i] + isCurrent := b.Branch == currentBranch + + bullet := "○" + if isCurrent { + bullet = "●" + } + + prInfo := "" + if clientErr == nil && repoErr == nil { + pr, err := client.FindPRForBranch(b.Branch) + if err == nil && pr != nil { + prInfo = fmt.Sprintf(" https://github.com/%s/%s/pull/%d", repoOwner, repoName, pr.Number) + } + } + + branchName := cfg.ColorMagenta(b.Branch) + if isCurrent { + branchName = cfg.ColorCyan(b.Branch + " (current)") + } + + fmt.Fprintf(&buf, "%s %s%s\n", bullet, branchName, prInfo) + + commits, err := git.Log(b.Branch, 1) + if err == nil && len(commits) > 0 { + c := commits[0] + short := c.SHA + if len(short) > 7 { + short = short[:7] + } + fmt.Fprintf(&buf, "│ %s %s\n", short, cfg.ColorGray("· "+timeAgo(c.Time))) + fmt.Fprintf(&buf, "│ %s\n", cfg.ColorGray(c.Subject)) + } + + fmt.Fprintf(&buf, "│\n") + } + + fmt.Fprintf(&buf, "└ %s\n", s.Trunk.Branch) + + return runPager(cfg, buf.String()) +} + +func runPager(cfg *config.Config, content string) error { + if !cfg.IsInteractive() { + _, err := fmt.Fprint(cfg.Out, content) + return err + } + + pagerCmd := os.Getenv("GIT_PAGER") + if pagerCmd == "" { + pagerCmd = os.Getenv("PAGER") + } + if pagerCmd == "" { + pagerCmd = "less" + } + + args := strings.Fields(pagerCmd) + if args[0] == "less" { + hasR := false + for _, a := range args[1:] { + if strings.Contains(a, "R") { + hasR = true + break + } + } + if !hasR { + args = append(args, "-R") + } + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = cfg.Out + cmd.Stderr = cfg.Err + cmd.Stdin = strings.NewReader(content) + + return cmd.Run() +} + +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + secs := int(d.Seconds()) + if secs == 1 { + return "1 second ago" + } + return fmt.Sprintf("%d seconds ago", secs) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case d < 30*24*time.Hour: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + default: + months := int(d.Hours() / 24 / 30) + if months <= 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + } +} + +func viewWeb(cfg *config.Config, s *stack.Stack) error { + client, err := cfg.GitHubClient() + if err != nil { + return err + } + + repo, err := cfg.Repo() + if err != nil { + return err + } + + b := browser.New("", cfg.Out, cfg.Err) + + opened := 0 + for _, br := range s.Branches { + pr, err := client.FindPRForBranch(br.Branch) + if err != nil || pr == nil { + continue + } + url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number) + if err := b.Browse(url); err != nil { + cfg.Warningf("failed to open %s: %v\n", url, err) + } else { + opened++ + } + } + + if opened == 0 { + cfg.Printf("No PRs found to open in browser.\n") + } else { + cfg.Successf("Opened %d PRs in browser\n", opened) + } + + return nil +} diff --git a/go.mod b/go.mod index 5df02d9..e53f761 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/cli/cli/v2 v2.86.0 github.com/cli/go-gh/v2 v2.13.0 + github.com/cli/shurcooL-graphql v0.0.4 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/spf13/cobra v1.10.2 ) @@ -17,8 +18,11 @@ require ( github.com/charmbracelet/x/ansi v0.10.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect github.com/cli/safeexec v1.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/henvic/httpretty v0.1.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -32,6 +36,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/thlib/go-timezone-local v0.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect diff --git a/go.sum b/go.sum index d909298..261a69b 100644 --- a/go.sum +++ b/go.sum @@ -16,12 +16,16 @@ github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/cli/cli/v2 v2.86.0 h1:114DaPhDvKNMp8MTLffN119mHe040eNhNgLv3qi3mNA= github.com/cli/cli/v2 v2.86.0/go.mod h1:cMrBHQOYc0MdNBseT5pUT6uxhvz4gcf010FEO7bWsP8= github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys= github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= +github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= +github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -31,6 +35,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= +github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -79,6 +89,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= +github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -97,6 +109,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -115,10 +128,14 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index c48f8f8..e2652e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,8 @@ import ( "github.com/cli/go-gh/v2/pkg/repository" "github.com/cli/go-gh/v2/pkg/term" "github.com/mgutz/ansi" + + ghapi "github.com/github/gh-stack/internal/github" ) // Config holds shared state for all commands. @@ -21,6 +23,8 @@ type Config struct { ColorError func(string) string ColorWarning func(string) string ColorBold func(string) string + ColorBlue func(string) string + ColorMagenta func(string) string ColorCyan func(string) string ColorGray func(string) string } @@ -40,14 +44,18 @@ func New() *Config { cfg.ColorError = ansi.ColorFunc("red") cfg.ColorWarning = ansi.ColorFunc("yellow") cfg.ColorBold = ansi.ColorFunc("default+b") + cfg.ColorBlue = ansi.ColorFunc("blue") + cfg.ColorMagenta = ansi.ColorFunc("magenta") cfg.ColorCyan = ansi.ColorFunc("cyan") - cfg.ColorGray = ansi.ColorFunc("white+d") + cfg.ColorGray = ansi.ColorFunc("default+d") } else { noop := func(s string) string { return s } cfg.ColorSuccess = noop cfg.ColorError = noop cfg.ColorWarning = noop cfg.ColorBold = noop + cfg.ColorBlue = noop + cfg.ColorMagenta = noop cfg.ColorCyan = noop cfg.ColorGray = noop } @@ -86,3 +94,11 @@ func (c *Config) IsInteractive() bool { func (c *Config) Repo() (repository.Repository, error) { return repository.Current() } + +func (c *Config) GitHubClient() (*ghapi.Client, error) { + repo, err := c.Repo() + if err != nil { + return nil, fmt.Errorf("determining repository: %w", err) + } + return ghapi.NewClient(repo.Owner, repo.Name) +} diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..46e128c --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,86 @@ +package github + +import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/api" + graphql "github.com/cli/shurcooL-graphql" +) + +// PullRequest represents a GitHub pull request. +type PullRequest struct { + ID string + Number int + Title string + State string + URL string + HeadRefName string + BaseRefName string + IsDraft bool +} + +// Client wraps GitHub API operations. +type Client struct { + gql *api.GraphQLClient + rest *api.RESTClient + owner string + repo string + slug string +} + +// NewClient creates a new GitHub API client for the given repository. +func NewClient(owner, repo string) (*Client, error) { + gql, err := api.DefaultGraphQLClient() + if err != nil { + return nil, fmt.Errorf("creating GraphQL client: %w", err) + } + rest, err := api.DefaultRESTClient() + if err != nil { + return nil, fmt.Errorf("creating REST client: %w", err) + } + return &Client{ + gql: gql, + rest: rest, + owner: owner, + repo: repo, + slug: owner + "/" + repo, + }, nil +} + +// FindPRForBranch finds an open PR by head branch name. +func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { + var query struct { + Repository struct { + PullRequests struct { + Nodes []PullRequest + } `graphql:"pullRequests(headRefName: $head, states: [OPEN], first: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "head": graphql.String(branch), + } + + if err := c.gql.Query("FindPRForBranch", &query, variables); err != nil { + return nil, fmt.Errorf("querying PRs: %w", err) + } + + nodes := query.Repository.PullRequests.Nodes + if len(nodes) == 0 { + return nil, nil + } + + n := nodes[0] + return &PullRequest{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + HeadRefName: n.HeadRefName, + BaseRefName: n.BaseRefName, + IsDraft: n.IsDraft, + }, nil +} From 775d0b3ef3412ee52a4f0db6db47dd860f9c7e76 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 18 Feb 2026 02:13:11 -0500 Subject: [PATCH 04/78] navigation commands --- cmd/navigate.go | 189 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 4 + 2 files changed, 193 insertions(+) create mode 100644 cmd/navigate.go diff --git a/cmd/navigate.go b/cmd/navigate.go new file mode 100644 index 0000000..763a280 --- /dev/null +++ b/cmd/navigate.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "fmt" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +func UpCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "up [n]", + Short: "Move up in the stack (toward the top)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + n := 1 + if len(args) > 0 { + fmt.Sscanf(args[0], "%d", &n) + } + return runNavigate(cfg, n) + }, + } +} + +func DownCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "down [n]", + Short: "Move down in the stack (toward the trunk)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + n := 1 + if len(args) > 0 { + fmt.Sscanf(args[0], "%d", &n) + } + return runNavigate(cfg, -n) + }, + } +} + +func TopCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "top", + Short: "Move to the top of the stack", + RunE: func(cmd *cobra.Command, args []string) error { + return runNavigateToEnd(cfg, true) + }, + } +} + +func BottomCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "bottom", + Short: "Move to the bottom of the stack", + RunE: func(cmd *cobra.Command, args []string) error { + return runNavigateToEnd(cfg, false) + }, + } +} + +func runNavigate(cfg *config.Config, delta int) error { + s, currentBranch, err := loadCurrentStack(cfg) + if err != nil { + return nil + } + + idx := s.IndexOf(currentBranch) + if idx < 0 { + // Might be on the trunk + if currentBranch == s.Trunk.Branch { + if delta > 0 && len(s.Branches) > 0 { + target := s.Branches[0].Branch + if err := git.CheckoutBranch(target); err != nil { + return err + } + cfg.Successf("Switched to %s", target) + return nil + } + cfg.Printf("already at the bottom of the stack") + return nil + } + cfg.Errorf("current branch %q is not in the stack", currentBranch) + return nil + } + + newIdx := idx + delta + if newIdx < 0 { + newIdx = 0 + } + if newIdx >= len(s.Branches) { + newIdx = len(s.Branches) - 1 + } + + if newIdx == idx { + if delta > 0 { + cfg.Printf("Already at the top of the stack") + } else { + cfg.Printf("Already at the bottom of the stack") + } + return nil + } + + target := s.Branches[newIdx].Branch + if err := git.CheckoutBranch(target); err != nil { + return err + } + + moved := newIdx - idx + direction := "up" + if moved < 0 { + direction = "down" + moved = -moved + } + + cfg.Successf("Moved %s %d %s to %s", direction, moved, plural(moved, "branch", "branches"), target) + return nil +} + +func runNavigateToEnd(cfg *config.Config, top bool) error { + s, currentBranch, err := loadCurrentStack(cfg) + if err != nil { + cfg.Errorf("failed to load current stack: %s", err) + return nil + } + + var target string + if top { + target = s.Branches[len(s.Branches)-1].Branch + } else { + target = s.Branches[0].Branch + } + + if target == currentBranch { + if top { + cfg.Printf("Already at the top of the stack") + } else { + cfg.Printf("Already at the bottom of the stack") + } + return nil + } + + if err := git.CheckoutBranch(target); err != nil { + return err + } + + cfg.Successf("Switched to %s", target) + return nil +} + +func loadCurrentStack(cfg *config.Config) (*stack.Stack, string, error) { + gitDir, err := git.GitDir() + if err != nil { + errMsg := "not a git repository" + cfg.Errorf("%s", errMsg) + return nil, "", fmt.Errorf("%s", errMsg) + } + + sf, err := stack.Load(gitDir) + if err != nil { + errMsg := fmt.Sprintf("failed to load stack state: %s", err) + cfg.Errorf("%s", errMsg) + return nil, "", fmt.Errorf("%s", errMsg) + } + + currentBranch, err := git.CurrentBranch() + if err != nil { + errMsg := fmt.Sprintf("failed to get current branch: %s", err) + cfg.Errorf("%s", errMsg) + return nil, "", fmt.Errorf("%s", errMsg) + } + + s := sf.FindStackForBranch(currentBranch) + if s == nil { + errMsg := fmt.Sprintf("current branch %q is not part of a stack", currentBranch) + cfg.Errorf("current branch %q is not part of a stack", currentBranch) + cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) + return nil, "", fmt.Errorf("%s", errMsg) + } + + return s, currentBranch, nil +} + +func plural(n int, singular, pluralForm string) string { + if n == 1 { + return singular + } + return pluralForm +} diff --git a/cmd/root.go b/cmd/root.go index 416d2f2..9a81b55 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,10 @@ func RootCmd() *cobra.Command { // Helper commands root.AddCommand(ViewCmd(cfg)) + root.AddCommand(UpCmd(cfg)) + root.AddCommand(DownCmd(cfg)) + root.AddCommand(TopCmd(cfg)) + root.AddCommand(BottomCmd(cfg)) // Placeholders for upcoming features for _, ph := range placeholderCommands { From cc21c25589d8f72696358e67590ddbf79c0d461d Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 18 Feb 2026 03:18:09 -0500 Subject: [PATCH 05/78] cascading rebase --- cmd/root.go | 3 + cmd/update.go | 386 ++++++++++++++++++++++++++++++++++++++++++++ internal/git/git.go | 70 +++++++- 3 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 cmd/update.go diff --git a/cmd/root.go b/cmd/root.go index 9a81b55..a5bd317 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,9 @@ func RootCmd() *cobra.Command { // Helper commands root.AddCommand(ViewCmd(cfg)) + root.AddCommand(UpdateCmd(cfg)) + + // Navigation commands root.AddCommand(UpCmd(cfg)) root.AddCommand(DownCmd(cfg)) root.AddCommand(TopCmd(cfg)) diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..94fa38e --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,386 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type updateOptions struct { + branch string + downstack bool + upstack bool + cont bool + abort bool +} + +type rebaseState struct { + CurrentBranchIndex int `json:"currentBranchIndex"` + ConflictBranch string `json:"conflictBranch"` + RemainingBranches []string `json:"remainingBranches"` + OriginalBranch string `json:"originalBranch"` + OriginalRefs map[string]string `json:"originalRefs"` +} + +const rebaseStateFile = "gh-stack-rebase-state" + +func UpdateCmd(cfg *config.Config) *cobra.Command { + opts := &updateOptions{} + + cmd := &cobra.Command{ + Use: "update [branch]", + Short: "Update and rebase a stack of branches", + Long: `Pull from remote and do a cascading rebase across the stack. + +Ensures that each branch in the stack has the tip of the previous +layer in its commit history, rebasing if necessary.`, + Example: ` $ gh stack update + $ gh stack update --downstack + $ gh stack update --continue + $ gh stack update --abort`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.branch = args[0] + } + return runUpdate(cfg, opts) + }, + } + + cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only update branches from trunk to current branch") + cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only update branches from current branch to top") + cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts") + cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches") + + return cmd +} + +func runUpdate(cfg *config.Config, opts *updateOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + if opts.cont { + return continueUpdate(cfg, gitDir) + } + + if opts.abort { + return abortUpdate(cfg, gitDir) + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + currentBranch := opts.branch + if currentBranch == "" { + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("unable to determine current branch: %s", err) + return nil + } + } + + s := sf.FindStackForBranch(currentBranch) + if s == nil { + cfg.Errorf("no stack found for branch %s", currentBranch) + return nil + } + + cfg.Printf("Fetching origin ...") + if err := git.Fetch("origin"); err != nil { + cfg.Warningf("Failed to fetch origin: %v", err) + } else { + cfg.Successf("Fetching origin") + } + + chainParts := []string{s.Trunk.Branch} + for _, b := range s.Branches { + chainParts = append(chainParts, b.Branch) + } + cfg.Printf("Stack detected: %s", joinChain(chainParts)) + + currentIdx := s.IndexOf(currentBranch) + if currentIdx < 0 { + currentIdx = 0 + } + + startIdx := 0 + endIdx := len(s.Branches) + + if opts.downstack { + endIdx = currentIdx + 1 + } + if opts.upstack { + startIdx = currentIdx + } + + branchesToUpdate := s.Branches[startIdx:endIdx] + + if len(branchesToUpdate) == 0 { + cfg.Printf("No branches to update") + return nil + } + + cfg.Printf("Updating branches in order, starting from %s to %s", + branchesToUpdate[0].Branch, branchesToUpdate[len(branchesToUpdate)-1].Branch) + + originalRefs := make(map[string]string) + for _, b := range s.Branches { + sha, _ := git.HeadSHA(b.Branch) + originalRefs[b.Branch] = sha + } + + for i, br := range branchesToUpdate { + var base string + absIdx := startIdx + i + if absIdx == 0 { + base = s.Trunk.Branch + } else { + base = s.Branches[absIdx-1].Branch + } + + cfg.Printf("Rebasing %s onto %s ...", br.Branch, base) + + if err := git.CheckoutBranch(br.Branch); err != nil { + return fmt.Errorf("checking out %s: %w", br.Branch, err) + } + + if err := git.Rebase(base); err != nil { + cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base) + + remaining := make([]string, 0) + for j := i + 1; j < len(branchesToUpdate); j++ { + remaining = append(remaining, branchesToUpdate[j].Branch) + } + + state := &rebaseState{ + CurrentBranchIndex: absIdx, + ConflictBranch: br.Branch, + RemainingBranches: remaining, + OriginalBranch: currentBranch, + OriginalRefs: originalRefs, + } + saveRebaseState(gitDir, state) + + printConflictDetails(cfg, base) + cfg.Printf("") + + cfg.Printf("Resolve conflicts on %s, then run %s", + br.Branch, cfg.ColorCyan("gh stack update --continue")) + cfg.Printf("Or abort this operation with %s", + cfg.ColorCyan("gh stack update --abort")) + return fmt.Errorf("rebase conflict on %s", br.Branch) + } + + cfg.Successf("Rebasing %s onto %s", br.Branch, base) + } + + _ = git.CheckoutBranch(currentBranch) + + for i := range s.Branches { + sha, _ := git.HeadSHA(s.Branches[i].Branch) + s.Branches[i].Head = sha + } + _ = stack.Save(gitDir, sf) + + rangeDesc := "All branches in stack" + if opts.downstack { + rangeDesc = fmt.Sprintf("All downstack branches up to %s", currentBranch) + } else if opts.upstack { + rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch) + } + + cfg.Printf("%s updated locally with %s", rangeDesc, s.Trunk.Branch) + cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", + cfg.ColorCyan("gh stack push -f")) + + return nil +} + +func continueUpdate(cfg *config.Config, gitDir string) error { + state, err := loadRebaseState(gitDir) + if err != nil { + cfg.Errorf("no rebase in progress") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + // Use the saved original branch to find the stack, since git may be in + // a detached HEAD state during an active rebase. + s := sf.FindStackForBranch(state.OriginalBranch) + if s == nil { + return fmt.Errorf("no stack found for branch %s", state.OriginalBranch) + } + + // The branch that had the conflict is stored in state; fall back to + // looking it up by index for backwards compatibility with older state files. + conflictBranch := state.ConflictBranch + if conflictBranch == "" && state.CurrentBranchIndex >= 0 && state.CurrentBranchIndex < len(s.Branches) { + conflictBranch = s.Branches[state.CurrentBranchIndex].Branch + } + + cfg.Printf("Continuing update of stack, resuming from %s to %s", + conflictBranch, s.Branches[len(s.Branches)-1].Branch) + + if git.IsRebaseInProgress() { + if err := git.RebaseContinue(); err != nil { + return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) + } + } + + var baseBranch string + if state.CurrentBranchIndex > 0 { + baseBranch = s.Branches[state.CurrentBranchIndex-1].Branch + } else { + baseBranch = s.Trunk.Branch + } + cfg.Successf("Rebasing %s onto %s", conflictBranch, baseBranch) + + for _, branchName := range state.RemainingBranches { + idx := s.IndexOf(branchName) + var base string + if idx == 0 { + base = s.Trunk.Branch + } else { + base = s.Branches[idx-1].Branch + } + + cfg.Printf("Rebasing %s onto %s ...", branchName, base) + + if err := git.CheckoutBranch(branchName); err != nil { + cfg.Errorf("checking out %s: %s", branchName, err) + return nil + } + + if err := git.Rebase(base); err != nil { + remainIdx := -1 + for ri, rb := range state.RemainingBranches { + if rb == branchName { + remainIdx = ri + break + } + } + state.RemainingBranches = state.RemainingBranches[remainIdx+1:] + state.CurrentBranchIndex = idx + state.ConflictBranch = branchName + saveRebaseState(gitDir, state) + + cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, base) + printConflictDetails(cfg, base) + cfg.Printf("") + cfg.Printf("Resolve conflicts on %s, then run %s", + branchName, cfg.ColorCyan("gh stack update --continue")) + cfg.Printf("Or abort this operation with %s", + cfg.ColorCyan("gh stack update --abort")) + return fmt.Errorf("rebase conflict on %s", branchName) + } + + cfg.Successf("Rebasing %s onto %s", branchName, base) + } + + clearRebaseState(gitDir) + _ = git.CheckoutBranch(state.OriginalBranch) + + for i := range s.Branches { + sha, _ := git.HeadSHA(s.Branches[i].Branch) + s.Branches[i].Head = sha + } + _ = stack.Save(gitDir, sf) + + cfg.Printf("All branches in stack updated locally with %s", s.Trunk.Branch) + cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", + cfg.ColorCyan("gh stack push -f")) + + return nil +} + +func abortUpdate(cfg *config.Config, gitDir string) error { + state, err := loadRebaseState(gitDir) + if err != nil { + cfg.Errorf("no rebase in progress") + return nil + } + + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + + for branch, sha := range state.OriginalRefs { + _ = git.CheckoutBranch(branch) + _ = git.ResetHard(sha) + } + + _ = git.CheckoutBranch(state.OriginalBranch) + clearRebaseState(gitDir) + cfg.Successf("Rebase aborted and branches restored") + + return nil +} + +func saveRebaseState(gitDir string, state *rebaseState) { + data, _ := json.MarshalIndent(state, "", " ") + _ = os.WriteFile(filepath.Join(gitDir, rebaseStateFile), data, 0644) +} + +func loadRebaseState(gitDir string) (*rebaseState, error) { + data, err := os.ReadFile(filepath.Join(gitDir, rebaseStateFile)) + if err != nil { + return nil, err + } + var state rebaseState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func clearRebaseState(gitDir string) { + _ = os.Remove(filepath.Join(gitDir, rebaseStateFile)) +} + +func printConflictDetails(cfg *config.Config, branch string) { + files, err := git.ConflictedFiles() + if err != nil || len(files) == 0 { + return + } + + cfg.Printf("") + cfg.Printf("%s", cfg.ColorBold("Conflicted files:")) + for _, f := range files { + info, err := git.FindConflictMarkers(f) + if err != nil || len(info.Sections) == 0 { + cfg.Printf(" %s %s", cfg.ColorWarning("C"), f) + continue + } + for _, sec := range info.Sections { + cfg.Printf(" %s %s (lines %d–%d)", + cfg.ColorWarning("C"), f, sec.StartLine, sec.EndLine) + } + } + + cfg.Printf("") + cfg.Printf("%s", cfg.ColorBold("To resolve:")) + cfg.Printf(" 1. Open each conflicted file and look for conflict markers:") + cfg.Printf(" %s (incoming changes from %s)", cfg.ColorCyan("<<<<<<< HEAD"), branch) + cfg.Printf(" %s", cfg.ColorCyan("=======")) + cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>")) + cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers") + cfg.Printf(" 3. Stage resolved files: %s", cfg.ColorCyan("git add ")) + cfg.Printf(" 4. Continue the update: %s", cfg.ColorCyan("gh stack update --continue")) +} diff --git a/internal/git/git.go b/internal/git/git.go index 54acbb7..79723af 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,6 +3,7 @@ package git import ( "context" "fmt" + "os" "os/exec" "strconv" "strings" @@ -110,8 +111,12 @@ func Rebase(onto string) error { } // RebaseContinue continues an in-progress rebase. +// It sets GIT_EDITOR=true to prevent git from opening an interactive editor +// for the commit message, which would cause the command to hang. func RebaseContinue() error { - return runSilent("rebase", "--continue") + cmd := exec.Command("git", "rebase", "--continue") + cmd.Env = append(os.Environ(), "GIT_EDITOR=true") + return cmd.Run() } // RebaseAbort aborts an in-progress rebase. @@ -134,6 +139,69 @@ func IsRebaseInProgress() bool { return false } +// ConflictedFiles returns the list of files that have merge conflicts. +func ConflictedFiles() ([]string, error) { + output, err := run("diff", "--name-only", "--diff-filter=U") + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + return strings.Split(output, "\n"), nil +} + +// ConflictMarkerInfo holds the location of conflict markers in a file. +type ConflictMarkerInfo struct { + File string + Sections []ConflictSection +} + +// ConflictSection represents a single conflict hunk in a file. +type ConflictSection struct { + StartLine int // line number of <<<<<<< + EndLine int // line number of >>>>>>> +} + +// FindConflictMarkers scans a file for conflict markers and returns their locations. +func FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { + output, err := run("diff", "--check", "--", filePath) + // git diff --check exits non-zero when conflicts exist, so we parse even on error + if output == "" && err != nil { + return nil, err + } + + info := &ConflictMarkerInfo{File: filePath} + var currentSection *ConflictSection + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Format: "filename:lineno: leftover conflict marker" + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + lineNo, parseErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if parseErr != nil { + continue + } + marker := strings.TrimSpace(parts[2]) + if strings.Contains(marker, "leftover conflict marker") { + if currentSection == nil || currentSection.EndLine != 0 { + currentSection = &ConflictSection{StartLine: lineNo} + info.Sections = append(info.Sections, *currentSection) + } + // Update the end line of the last section + info.Sections[len(info.Sections)-1].EndLine = lineNo + } + } + + return info, nil +} + // HeadSHA returns the full SHA of the given ref. func HeadSHA(ref string) (string, error) { return run("rev-parse", ref) From 58d154999d041ba986bd486d9db8dd9594c9b039 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 18 Feb 2026 03:57:18 -0500 Subject: [PATCH 06/78] placeholders for checkout and merge --- cmd/checkout.go | 43 +++++++++++++++++++++++++++++++++++++++++++ cmd/merge.go | 31 +++++++++++++++++++++++++++++++ cmd/root.go | 4 ++++ 3 files changed, 78 insertions(+) create mode 100644 cmd/checkout.go create mode 100644 cmd/merge.go diff --git a/cmd/checkout.go b/cmd/checkout.go new file mode 100644 index 0000000..3584845 --- /dev/null +++ b/cmd/checkout.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +type checkoutOptions struct { + target string + noSwitch bool +} + +func CheckoutCmd(cfg *config.Config) *cobra.Command { + opts := &checkoutOptions{} + + cmd := &cobra.Command{ + Use: "checkout ", + Short: "Checkout a stack from a PR number or branch name", + Long: "Discover and check out an entire stack from a pull request number, URL, or branch name.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.target = args[0] + return runCheckout(cfg, opts) + }, + } + + cmd.Flags().BoolVar(&opts.noSwitch, "no-switch", false, "Fetch and track the stack without switching branches") + + return cmd +} + +// runCheckout is a placeholder for the stack checkout workflow. +// +// The intended behavior is: +// 1. Resolve the target (PR number, URL, or branch name) to a PR +// 2. If the PR is part of a stack, discover the full set of PRs in the stack +// 3. Fetch and create local tracking branches for every branch in the stack +// 4. Save the stack to local tracking (.git/gh-stack, similar to gh stack init --adopt) +// 5. Switch to the target branch (unless --no-switch is set) +func runCheckout(cfg *config.Config, opts *checkoutOptions) error { + cfg.Warningf("gh stack checkout is not yet implemented\n") + return nil +} diff --git a/cmd/merge.go b/cmd/merge.go new file mode 100644 index 0000000..e1928ba --- /dev/null +++ b/cmd/merge.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +func MergeCmd(cfg *config.Config) *cobra.Command { + opts := struct{}{} + + cmd := &cobra.Command{ + Use: "merge ", + Short: "Merge a stack of PRs", + Long: "Merges the specified PR and all PRs below it in the stack.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMerge(cfg, opts) + }, + } + + return cmd +} + +// runMerge is a placeholder for the stack merge workflow. +// +// We need a mergeability check for the entire stack +// and an endpoint for merging an entire stack +func runMerge(cfg *config.Config, opts struct{}) error { + cfg.Warningf("gh stack merge is not yet implemented\n") + return nil +} diff --git a/cmd/root.go b/cmd/root.go index a5bd317..51af217 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,10 @@ func RootCmd() *cobra.Command { root.AddCommand(InitCmd(cfg)) root.AddCommand(AddCmd(cfg)) + // Remote operations + root.AddCommand(CheckoutCmd(cfg)) + root.AddCommand(MergeCmd(cfg)) + // Helper commands root.AddCommand(ViewCmd(cfg)) root.AddCommand(UpdateCmd(cfg)) From ad9d170f588d266a2b84620daa5797ee5654c6e6 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 18 Feb 2026 04:15:54 -0500 Subject: [PATCH 07/78] push branches and create prs --- cmd/push.go | 144 ++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + internal/github/github.go | 103 +++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 cmd/push.go diff --git a/cmd/push.go b/cmd/push.go new file mode 100644 index 0000000..e676fc0 --- /dev/null +++ b/cmd/push.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "fmt" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type pushOptions struct { + force bool + draft bool + dryRun bool +} + +func PushCmd(cfg *config.Config) *cobra.Command { + opts := &pushOptions{} + + cmd := &cobra.Command{ + Use: "push", + Short: "Push all branches in the current stack and create/update PRs", + RunE: func(cmd *cobra.Command, args []string) error { + return runPush(cfg, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Force-push branches") + cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Show what would be pushed without pushing") + + return cmd +} + +func runPush(cfg *config.Config, opts *pushOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + + s := sf.FindStackForBranch(currentBranch) + if s == nil { + cfg.Errorf("current branch %q is not part of a stack", currentBranch) + return nil + } + + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return nil + } + + // Push all branches + for _, b := range s.Branches { + if opts.dryRun { + cfg.Printf("Would push %s\n", b.Branch) + continue + } + + cfg.Printf("Pushing %s...\n", b.Branch) + if err := git.Push("origin", []string{b.Branch}, opts.force, false); err != nil { + cfg.Errorf("failed to push %s: %s", b.Branch, err) + return nil + } + } + + if opts.dryRun { + return nil + } + + // Create or update PRs + for i, b := range s.Branches { + baseBranch := s.BaseBranch(b.Branch) + + pr, err := client.FindPRForBranch(b.Branch) + if err != nil { + cfg.Warningf("failed to check PR for %s: %v\n", b.Branch, err) + continue + } + + if pr == nil { + // Create new PR + title := b.Branch + body := fmt.Sprintf("Part %d of stack.\n\nBase: `%s`", i+1, baseBranch) + + newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) + if createErr != nil { + cfg.Warningf("failed to create PR for %s: %v\n", b.Branch, createErr) + continue + } + cfg.Successf("Created PR #%d for %s\n", newPR.Number, b.Branch) + } else { + // Update base if needed + if pr.BaseRefName != baseBranch { + if err := client.UpdatePRBase(pr.ID, baseBranch); err != nil { + cfg.Warningf("failed to update PR #%d base: %v\n", pr.Number, err) + } else { + cfg.Successf("Updated PR #%d base to %s\n", pr.Number, baseBranch) + } + } else { + cfg.Printf("PR #%d for %s is up to date\n", pr.Number, b.Branch) + } + } + } + + // TODO: Add PRs to a stack + // + // We can call an API after all the individual PRs are created/updated to create the stack at once, + // or we can add a flag to the existing PR API to incrementally build the stack. + // + // For now, the PRs are pushed and created individually but are NOT linked as a formal stack on GitHub. + cfg.Warningf("Stacked PRs is not yet implemented — PRs were created individually.\n") + fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n") + fmt.Fprintf(cfg.Err, " grouped into a Stack.\n") + + // Update head SHAs + for i, b := range s.Branches { + if sha, err := git.HeadSHA(b.Branch); err == nil { + s.Branches[i].Head = sha + } + } + + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return nil + } + + cfg.Successf("Pushed and synced %d branches\n", len(s.Branches)) + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 51af217..4692952 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,7 @@ func RootCmd() *cobra.Command { // Remote operations root.AddCommand(CheckoutCmd(cfg)) + root.AddCommand(PushCmd(cfg)) root.AddCommand(MergeCmd(cfg)) // Helper commands diff --git a/internal/github/github.go b/internal/github/github.go index 46e128c..5d8b240 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -84,3 +84,106 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { IsDraft: n.IsDraft, }, nil } + +// CreatePR creates a new pull request. +func (c *Client) CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) { + var mutation struct { + CreatePullRequest struct { + PullRequest struct { + ID string + Number int + Title string + State string + URL string `graphql:"url"` + HeadRefName string + BaseRefName string + IsDraft bool + } + } `graphql:"createPullRequest(input: $input)"` + } + + repoID, err := c.repositoryID() + if err != nil { + return nil, err + } + + type CreatePullRequestInput struct { + RepositoryID string `json:"repositoryId"` + BaseRefName string `json:"baseRefName"` + HeadRefName string `json:"headRefName"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Draft bool `json:"draft"` + } + + variables := map[string]interface{}{ + "input": CreatePullRequestInput{ + RepositoryID: repoID, + BaseRefName: base, + HeadRefName: head, + Title: title, + Body: body, + Draft: draft, + }, + } + + if err := c.gql.Mutate("CreatePullRequest", &mutation, variables); err != nil { + return nil, fmt.Errorf("creating PR: %w", err) + } + + pr := mutation.CreatePullRequest.PullRequest + return &PullRequest{ + ID: pr.ID, + Number: pr.Number, + Title: pr.Title, + State: pr.State, + URL: pr.URL, + HeadRefName: pr.HeadRefName, + BaseRefName: pr.BaseRefName, + IsDraft: pr.IsDraft, + }, nil +} + +// UpdatePRBase updates the base branch of a pull request. +func (c *Client) UpdatePRBase(prID, newBase string) error { + var mutation struct { + UpdatePullRequest struct { + PullRequest struct { + ID string + } + } `graphql:"updatePullRequest(input: $input)"` + } + + type UpdatePullRequestInput struct { + PullRequestID string `json:"pullRequestId"` + BaseRefName string `json:"baseRefName"` + } + + variables := map[string]interface{}{ + "input": UpdatePullRequestInput{ + PullRequestID: prID, + BaseRefName: newBase, + }, + } + + return c.gql.Mutate("UpdatePullRequest", &mutation, variables) +} + +func (c *Client) repositoryID() (string, error) { + var query struct { + Repository struct { + ID string + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + } + + if err := c.gql.Query("RepositoryID", &query, variables); err != nil { + return "", fmt.Errorf("fetching repository ID: %w", err) + } + + return query.Repository.ID, nil +} From 86d342e88f731a889c9af39779fe8cf8100c060e Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 9 Mar 2026 09:50:06 -0400 Subject: [PATCH 08/78] delete stack locally --- cmd/root.go | 1 + cmd/unstack.go | 89 +++++++++++++++++++++++++++++++++++++++ internal/github/github.go | 6 +++ 3 files changed, 96 insertions(+) create mode 100644 cmd/unstack.go diff --git a/cmd/root.go b/cmd/root.go index 4692952..62e0507 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,7 @@ func RootCmd() *cobra.Command { // Remote operations root.AddCommand(CheckoutCmd(cfg)) root.AddCommand(PushCmd(cfg)) + root.AddCommand(UnstackCmd(cfg)) root.AddCommand(MergeCmd(cfg)) // Helper commands diff --git a/cmd/unstack.go b/cmd/unstack.go new file mode 100644 index 0000000..98f1676 --- /dev/null +++ b/cmd/unstack.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type unstackOptions struct { + target string + local bool +} + +func UnstackCmd(cfg *config.Config) *cobra.Command { + opts := &unstackOptions{} + + cmd := &cobra.Command{ + Use: "unstack [branch]", + Short: "Delete a stack locally and on GitHub", + Long: "Remove a stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.target = args[0] + } + return runUnstack(cfg, opts) + }, + } + + cmd.Flags().BoolVar(&opts.local, "local", false, "Only delete the stack locally") + + return cmd +} + +func runUnstack(cfg *config.Config, opts *unstackOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + target := opts.target + if target == "" { + target, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("unable to determine current branch: %s", err) + return nil + } + } + + s := sf.FindStackForBranch(target) + if s == nil { + cfg.Errorf("branch %q is not part of a stack", target) + return nil + } + + cfg.Printf("Stack branches: %v", s.BranchNames()) + + // Remove from local tracking + sf.RemoveStackForBranch(target) + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return nil + } + cfg.Successf("Stack removed from local tracking") + + // Delete the stack on GitHub + if !opts.local { + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return nil + } + if err := client.DeleteStack(); err != nil { + cfg.Warningf("%v", err) + } else { + cfg.Successf("Stack deleted on GitHub") + } + } + + return nil +} diff --git a/internal/github/github.go b/internal/github/github.go index 5d8b240..563634f 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -169,6 +169,12 @@ func (c *Client) UpdatePRBase(prID, newBase string) error { return c.gql.Mutate("UpdatePullRequest", &mutation, variables) } +// DeleteStack deletes a stack on GitHub. +// TODO: Implement once the stack API is available. +func (c *Client) DeleteStack() error { + return fmt.Errorf("deleting a stack on GitHub is not yet supported by the API") +} + func (c *Client) repositoryID() (string, error) { var query struct { Repository struct { From 157d87c045655a5c4afbae33c50267f8519c45c1 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 9 Mar 2026 17:06:38 -0400 Subject: [PATCH 09/78] rename update to rebase --- cmd/{update.go => rebase.go} | 66 ++++++++++++++++++------------------ cmd/root.go | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) rename cmd/{update.go => rebase.go} (84%) diff --git a/cmd/update.go b/cmd/rebase.go similarity index 84% rename from cmd/update.go rename to cmd/rebase.go index 94fa38e..eaf5a4b 100644 --- a/cmd/update.go +++ b/cmd/rebase.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -type updateOptions struct { +type rebaseOptions struct { branch string downstack bool upstack bool @@ -30,38 +30,38 @@ type rebaseState struct { const rebaseStateFile = "gh-stack-rebase-state" -func UpdateCmd(cfg *config.Config) *cobra.Command { - opts := &updateOptions{} +func RebaseCmd(cfg *config.Config) *cobra.Command { + opts := &rebaseOptions{} cmd := &cobra.Command{ - Use: "update [branch]", - Short: "Update and rebase a stack of branches", + Use: "rebase [branch]", + Short: "Rebase a stack of branches", Long: `Pull from remote and do a cascading rebase across the stack. Ensures that each branch in the stack has the tip of the previous layer in its commit history, rebasing if necessary.`, - Example: ` $ gh stack update - $ gh stack update --downstack - $ gh stack update --continue - $ gh stack update --abort`, + Example: ` $ gh stack rebase + $ gh stack rebase --downstack + $ gh stack rebase --continue + $ gh stack rebase --abort`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.branch = args[0] } - return runUpdate(cfg, opts) + return runRebase(cfg, opts) }, } - cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only update branches from trunk to current branch") - cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only update branches from current branch to top") + cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only rebase branches from trunk to current branch") + cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only rebase branches from current branch to top") cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts") cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches") return cmd } -func runUpdate(cfg *config.Config, opts *updateOptions) error { +func runRebase(cfg *config.Config, opts *rebaseOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") @@ -69,11 +69,11 @@ func runUpdate(cfg *config.Config, opts *updateOptions) error { } if opts.cont { - return continueUpdate(cfg, gitDir) + return continueRebase(cfg, gitDir) } if opts.abort { - return abortUpdate(cfg, gitDir) + return abortRebase(cfg, gitDir) } sf, err := stack.Load(gitDir) @@ -125,15 +125,15 @@ func runUpdate(cfg *config.Config, opts *updateOptions) error { startIdx = currentIdx } - branchesToUpdate := s.Branches[startIdx:endIdx] + branchesToRebase := s.Branches[startIdx:endIdx] - if len(branchesToUpdate) == 0 { - cfg.Printf("No branches to update") + if len(branchesToRebase) == 0 { + cfg.Printf("No branches to rebase") return nil } - cfg.Printf("Updating branches in order, starting from %s to %s", - branchesToUpdate[0].Branch, branchesToUpdate[len(branchesToUpdate)-1].Branch) + cfg.Printf("Rebasing branches in order, starting from %s to %s", + branchesToRebase[0].Branch, branchesToRebase[len(branchesToRebase)-1].Branch) originalRefs := make(map[string]string) for _, b := range s.Branches { @@ -141,7 +141,7 @@ func runUpdate(cfg *config.Config, opts *updateOptions) error { originalRefs[b.Branch] = sha } - for i, br := range branchesToUpdate { + for i, br := range branchesToRebase { var base string absIdx := startIdx + i if absIdx == 0 { @@ -160,8 +160,8 @@ func runUpdate(cfg *config.Config, opts *updateOptions) error { cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base) remaining := make([]string, 0) - for j := i + 1; j < len(branchesToUpdate); j++ { - remaining = append(remaining, branchesToUpdate[j].Branch) + for j := i + 1; j < len(branchesToRebase); j++ { + remaining = append(remaining, branchesToRebase[j].Branch) } state := &rebaseState{ @@ -177,9 +177,9 @@ func runUpdate(cfg *config.Config, opts *updateOptions) error { cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run %s", - br.Branch, cfg.ColorCyan("gh stack update --continue")) + br.Branch, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with %s", - cfg.ColorCyan("gh stack update --abort")) + cfg.ColorCyan("gh stack rebase --abort")) return fmt.Errorf("rebase conflict on %s", br.Branch) } @@ -201,14 +201,14 @@ func runUpdate(cfg *config.Config, opts *updateOptions) error { rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch) } - cfg.Printf("%s updated locally with %s", rangeDesc, s.Trunk.Branch) + cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch) cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", cfg.ColorCyan("gh stack push -f")) return nil } -func continueUpdate(cfg *config.Config, gitDir string) error { +func continueRebase(cfg *config.Config, gitDir string) error { state, err := loadRebaseState(gitDir) if err != nil { cfg.Errorf("no rebase in progress") @@ -235,7 +235,7 @@ func continueUpdate(cfg *config.Config, gitDir string) error { conflictBranch = s.Branches[state.CurrentBranchIndex].Branch } - cfg.Printf("Continuing update of stack, resuming from %s to %s", + cfg.Printf("Continuing rebase of stack, resuming from %s to %s", conflictBranch, s.Branches[len(s.Branches)-1].Branch) if git.IsRebaseInProgress() { @@ -285,9 +285,9 @@ func continueUpdate(cfg *config.Config, gitDir string) error { printConflictDetails(cfg, base) cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run %s", - branchName, cfg.ColorCyan("gh stack update --continue")) + branchName, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with %s", - cfg.ColorCyan("gh stack update --abort")) + cfg.ColorCyan("gh stack rebase --abort")) return fmt.Errorf("rebase conflict on %s", branchName) } @@ -303,14 +303,14 @@ func continueUpdate(cfg *config.Config, gitDir string) error { } _ = stack.Save(gitDir, sf) - cfg.Printf("All branches in stack updated locally with %s", s.Trunk.Branch) + cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch) cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", cfg.ColorCyan("gh stack push -f")) return nil } -func abortUpdate(cfg *config.Config, gitDir string) error { +func abortRebase(cfg *config.Config, gitDir string) error { state, err := loadRebaseState(gitDir) if err != nil { cfg.Errorf("no rebase in progress") @@ -382,5 +382,5 @@ func printConflictDetails(cfg *config.Config, branch string) { cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>")) cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers") cfg.Printf(" 3. Stage resolved files: %s", cfg.ColorCyan("git add ")) - cfg.Printf(" 4. Continue the update: %s", cfg.ColorCyan("gh stack update --continue")) + cfg.Printf(" 4. Continue the rebase: %s", cfg.ColorCyan("gh stack rebase --continue")) } diff --git a/cmd/root.go b/cmd/root.go index 62e0507..ef7644b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,7 +33,7 @@ func RootCmd() *cobra.Command { // Helper commands root.AddCommand(ViewCmd(cfg)) - root.AddCommand(UpdateCmd(cfg)) + root.AddCommand(RebaseCmd(cfg)) // Navigation commands root.AddCommand(UpCmd(cfg)) From f3db39759374ffbb3a268ee92183917ae8004a5a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 00:07:38 -0400 Subject: [PATCH 10/78] block init inside an existing stack --- cmd/init.go | 16 ++++++++++++---- internal/git/git.go | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 68addb6..c4e64f7 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -56,7 +56,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if trunk == "" { trunk, err = git.DefaultBranch() if err != nil { - cfg.Errorf("unable to determine default branch: %s\nUse -b to specify the trunk branch", err) + cfg.Errorf("unable to determine default branch\nUse -b to specify the trunk branch") return nil } } @@ -76,6 +76,14 @@ func runInit(cfg *config.Config, opts *initOptions) error { currentBranch, _ := git.CurrentBranch() + // Don't allow initializing a stack if the current branch is already part of another stack + if currentBranch != "" { + if existing := sf.FindStackForBranch(currentBranch); existing != nil { + cfg.Errorf("current branch %q is already part of a stack", currentBranch) + return nil + } + } + var branches []string if opts.adopt { @@ -90,7 +98,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { return nil } if err := sf.ValidateNoDuplicateBranch(b); err != nil { - cfg.Errorf("branch %q already exists in the stack", b) + cfg.Errorf("branch %q already exists in a stack", b) return nil } } @@ -99,7 +107,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Explicit branch names provided — create them for _, b := range opts.branches { if err := sf.ValidateNoDuplicateBranch(b); err != nil { - cfg.Errorf("branch %q already exists in the stack", b) + cfg.Errorf("branch %q already exists in a stack", b) return nil } if !git.BranchExists(b) { @@ -144,7 +152,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { return nil } if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { - cfg.Errorf("branch %q already exists in the stack", branchName) + cfg.Errorf("branch %q already exists in a stack", branchName) return nil } if !git.BranchExists(branchName) { diff --git a/internal/git/git.go b/internal/git/git.go index 79723af..24cd997 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,7 +2,6 @@ package git import ( "context" - "fmt" "os" "os/exec" "strconv" @@ -73,16 +72,17 @@ func Fetch(remote string) error { // --- Custom operations not available in cligit --- -// DefaultBranch returns the default branch of origin. +// DefaultBranch returns the HEAD branch from origin. func DefaultBranch() (string, error) { ref, err := run("symbolic-ref", "refs/remotes/origin/HEAD") + // fallback: if origin/HEAD doesn't exist, look for common default branch names if err != nil { for _, name := range []string{"main", "master"} { if BranchExists(name) { return name, nil } } - return "", fmt.Errorf("unable to determine default branch: %w", err) + return "", err } return strings.TrimPrefix(ref, "refs/remotes/origin/"), nil } From b39609a8d77313022dfedf169168bf49ae54a09c Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 01:10:28 -0400 Subject: [PATCH 11/78] disambiguate for multiple stacks --- cmd/add.go | 13 ++++++++- cmd/init.go | 2 +- cmd/navigate.go | 14 +++++++++- cmd/push.go | 13 ++++++++- cmd/rebase.go | 18 ++++++++++-- cmd/unstack.go | 6 +++- cmd/view.go | 13 ++++++++- internal/stack/resolve.go | 58 +++++++++++++++++++++++++++++++++++++++ internal/stack/stack.go | 19 ++++++++++--- 9 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 internal/stack/resolve.go diff --git a/cmd/add.go b/cmd/add.go index 75dc699..a68b388 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -40,12 +40,23 @@ func runAdd(cfg *config.Config, args []string) error { return nil } - s := sf.FindStackForBranch(currentBranch) + s, err := sf.ResolveStack(currentBranch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil + } if s == nil { cfg.Errorf("current branch %q is not part of a stack; run 'gh stack init' first", currentBranch) return nil } + // Re-read current branch in case disambiguation caused a checkout + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + idx := s.IndexOf(currentBranch) if idx >= 0 && idx < len(s.Branches)-1 { cfg.Errorf("can only add branches on top of the stack; checkout the top branch %q first", s.Branches[len(s.Branches)-1].Branch) diff --git a/cmd/init.go b/cmd/init.go index c4e64f7..7e82a89 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -78,7 +78,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Don't allow initializing a stack if the current branch is already part of another stack if currentBranch != "" { - if existing := sf.FindStackForBranch(currentBranch); existing != nil { + if stacks := sf.FindAllStacksForBranch(currentBranch); len(stacks) > 0 { cfg.Errorf("current branch %q is already part of a stack", currentBranch) return nil } diff --git a/cmd/navigate.go b/cmd/navigate.go index 763a280..b7cc04e 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -170,7 +170,11 @@ func loadCurrentStack(cfg *config.Config) (*stack.Stack, string, error) { return nil, "", fmt.Errorf("%s", errMsg) } - s := sf.FindStackForBranch(currentBranch) + s, err := sf.ResolveStack(currentBranch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil, "", err + } if s == nil { errMsg := fmt.Sprintf("current branch %q is not part of a stack", currentBranch) cfg.Errorf("current branch %q is not part of a stack", currentBranch) @@ -178,6 +182,14 @@ func loadCurrentStack(cfg *config.Config) (*stack.Stack, string, error) { return nil, "", fmt.Errorf("%s", errMsg) } + // Re-read current branch in case disambiguation caused a checkout + currentBranch, err = git.CurrentBranch() + if err != nil { + errMsg := fmt.Sprintf("failed to get current branch: %s", err) + cfg.Errorf("%s", errMsg) + return nil, "", fmt.Errorf("%s", errMsg) + } + return s, currentBranch, nil } diff --git a/cmd/push.go b/cmd/push.go index e676fc0..1d1f157 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -52,12 +52,23 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } - s := sf.FindStackForBranch(currentBranch) + s, err := sf.ResolveStack(currentBranch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil + } if s == nil { cfg.Errorf("current branch %q is not part of a stack", currentBranch) return nil } + // Re-read current branch in case disambiguation caused a checkout + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) diff --git a/cmd/rebase.go b/cmd/rebase.go index eaf5a4b..cfd07bb 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -91,12 +91,23 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } - s := sf.FindStackForBranch(currentBranch) + s, err := sf.ResolveStack(currentBranch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil + } if s == nil { cfg.Errorf("no stack found for branch %s", currentBranch) return nil } + // Re-read current branch in case disambiguation caused a checkout + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + cfg.Printf("Fetching origin ...") if err := git.Fetch("origin"); err != nil { cfg.Warningf("Failed to fetch origin: %v", err) @@ -223,7 +234,10 @@ func continueRebase(cfg *config.Config, gitDir string) error { // Use the saved original branch to find the stack, since git may be in // a detached HEAD state during an active rebase. - s := sf.FindStackForBranch(state.OriginalBranch) + s, err := sf.ResolveStack(state.OriginalBranch, cfg) + if err != nil { + return err + } if s == nil { return fmt.Errorf("no stack found for branch %s", state.OriginalBranch) } diff --git a/cmd/unstack.go b/cmd/unstack.go index 98f1676..de98ed2 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -55,7 +55,11 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { } } - s := sf.FindStackForBranch(target) + s, err := sf.ResolveStack(target, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil + } if s == nil { cfg.Errorf("branch %q is not part of a stack", target) return nil diff --git a/cmd/view.go b/cmd/view.go index 453f8ef..90fc8ad 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -56,13 +56,24 @@ func runView(cfg *config.Config, opts *viewOptions) error { return nil } - s := sf.FindStackForBranch(currentBranch) + s, err := sf.ResolveStack(currentBranch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil + } if s == nil { cfg.Errorf("current branch %q is not part of a stack", currentBranch) cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) return nil } + // Re-read current branch in case disambiguation caused a checkout + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + if opts.web { return viewWeb(cfg, s) } diff --git a/internal/stack/resolve.go b/internal/stack/resolve.go new file mode 100644 index 0000000..73850e7 --- /dev/null +++ b/internal/stack/resolve.go @@ -0,0 +1,58 @@ +package stack + +import ( + "fmt" + "os" + + "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" +) + +// ResolveStack finds the stack for the given branch, handling ambiguity when +// a branch (typically a trunk) belongs to multiple stacks. If exactly one +// stack matches, it is returned directly. If multiple stacks match, the user +// is prompted to select one and the working tree is switched to the top branch +// of the selected stack. Returns nil with no error if no stack contains the +// branch. +func (sf *StackFile) ResolveStack(branch string, cfg *config.Config) (*Stack, error) { + stacks := sf.FindAllStacksForBranch(branch) + + switch len(stacks) { + case 0: + return nil, nil + case 1: + return stacks[0], nil + } + + if !cfg.IsInteractive() { + return nil, fmt.Errorf("branch %q belongs to multiple stacks; use an interactive terminal to select one", branch) + } + + cfg.Warningf("Branch %q is the trunk of multiple stacks\n", branch) + + options := make([]string, len(stacks)) + for i, s := range stacks { + options[i] = s.DisplayName() + } + + p := prompter.New(os.Stdin, os.Stdout, os.Stderr) + selected, err := p.Select("Which stack would you like to use?", "", options) + if err != nil { + return nil, fmt.Errorf("stack selection: %w", err) + } + + s := stacks[selected] + + // Switch to the top branch of the selected stack so future commands + // resolve unambiguously. + topBranch := s.Branches[len(s.Branches)-1].Branch + if topBranch != branch { + if err := git.CheckoutBranch(topBranch); err != nil { + return nil, fmt.Errorf("failed to checkout branch %s: %w", topBranch, err) + } + cfg.Successf("Switched to %s\n", topBranch) + } + + return s, nil +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go index e320d32..ea0b699 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -25,6 +25,16 @@ type Stack struct { Branches []BranchRef `json:"branches"` } +// DisplayName returns a human-readable chain representation of the stack. +// Format: (trunk) <- branch1 <- branch2 <- branch3 +func (s *Stack) DisplayName() string { + result := "(" + s.Trunk.Branch + ")" + for _, b := range s.Branches { + result += " <- " + b.Branch + } + return result +} + // BranchNames returns the list of branch names in order. func (s *Stack) BranchNames() []string { names := make([]string, len(s.Branches)) @@ -69,14 +79,15 @@ type StackFile struct { Stacks []Stack `json:"stacks"` } -// FindStackForBranch returns the stack that contains the given branch, or nil. -func (sf *StackFile) FindStackForBranch(branch string) *Stack { +// FindAllStacksForBranch returns all stacks that contain the given branch. +func (sf *StackFile) FindAllStacksForBranch(branch string) []*Stack { + var stacks []*Stack for i := range sf.Stacks { if sf.Stacks[i].Contains(branch) { - return &sf.Stacks[i] + stacks = append(stacks, &sf.Stacks[i]) } } - return nil + return stacks } // ValidateNoDuplicateBranch checks that the branch is not already in any stack. From 3bf143c5e51403b1bc85798572b866638ed21013 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 01:46:06 -0400 Subject: [PATCH 12/78] collect feedback via discussions --- cmd/feedback.go | 42 ++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 3 +++ 2 files changed, 45 insertions(+) create mode 100644 cmd/feedback.go diff --git a/cmd/feedback.go b/cmd/feedback.go new file mode 100644 index 0000000..24e398d --- /dev/null +++ b/cmd/feedback.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "net/url" + "strings" + + "github.com/cli/go-gh/v2/pkg/browser" + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +const feedbackBaseURL = "https://github.com/github/gh-stack/discussions/new?category=feedback" + +func FeedbackCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "feedback [title]", + Short: "Submit feedback for gh-stack", + Long: "Opens a GitHub Discussion in the gh-stack repository to submit feedback. Optionally provide a title for the discussion post.", + RunE: func(cmd *cobra.Command, args []string) error { + return runFeedback(cfg, args) + }, + } + + return cmd +} + +func runFeedback(cfg *config.Config, args []string) error { + feedbackURL := feedbackBaseURL + + if len(args) > 0 { + title := strings.Join(args, " ") + feedbackURL += "&title=" + url.QueryEscape(title) + } + + b := browser.New("", cfg.Out, cfg.Err) + if err := b.Browse(feedbackURL); err != nil { + return err + } + + cfg.Successf("Opening feedback form in your browser...\n") + return nil +} diff --git a/cmd/root.go b/cmd/root.go index ef7644b..3feb4a7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -41,6 +41,9 @@ func RootCmd() *cobra.Command { root.AddCommand(TopCmd(cfg)) root.AddCommand(BottomCmd(cfg)) + // Feedback + root.AddCommand(FeedbackCmd(cfg)) + // Placeholders for upcoming features for _, ph := range placeholderCommands { root.AddCommand(PlaceholderCmd(ph, cfg)) From fdaa11fba076f7389a8eddb6535df424b87c27a9 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 17:06:05 -0400 Subject: [PATCH 13/78] updated storage format --- cmd/add.go | 4 ++-- cmd/init.go | 10 +++++++--- cmd/push.go | 26 ++++++++++++++++++++++---- cmd/rebase.go | 16 ++++++++++++---- internal/git/git.go | 5 +++++ internal/stack/stack.go | 23 ++++++++++++++++++++--- 6 files changed, 68 insertions(+), 16 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index a68b388..88fe269 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -98,8 +98,8 @@ func runAdd(cfg *config.Config, args []string) error { return nil } - head, _ := git.HeadSHA(branchName) - s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Head: head}) + base, _ := git.HeadSHA(currentBranch) + s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base}) if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) diff --git a/cmd/init.go b/cmd/init.go index 7e82a89..c82cf33 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -71,7 +71,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Set repository context repo, err := cfg.Repo() if err == nil { - sf.Repository = repo.Owner + "/" + repo.Name + sf.Repository = repo.Host + ":" + repo.Owner + "/" + repo.Name } currentBranch, _ := git.CurrentBranch() @@ -169,8 +169,12 @@ func runInit(cfg *config.Config, opts *initOptions) error { trunkSHA, _ := git.HeadSHA(trunk) branchRefs := make([]stack.BranchRef, len(branches)) for i, b := range branches { - sha, _ := git.HeadSHA(b) - branchRefs[i] = stack.BranchRef{Branch: b, Head: sha} + parent := trunk + if i > 0 { + parent = branches[i-1] + } + base, _ := git.MergeBase(b, parent) + branchRefs[i] = stack.BranchRef{Branch: b, Base: base} } newStack := stack.Stack{ diff --git a/cmd/push.go b/cmd/push.go index 1d1f157..1d4ca01 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -114,6 +114,12 @@ func runPush(cfg *config.Config, opts *pushOptions) error { continue } cfg.Successf("Created PR #%d for %s\n", newPR.Number, b.Branch) + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: newPR.Number, + ID: newPR.ID, + URL: newPR.URL, + Title: newPR.Title, + } } else { // Update base if needed if pr.BaseRefName != baseBranch { @@ -125,6 +131,14 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } else { cfg.Printf("PR #%d for %s is up to date\n", pr.Number, b.Branch) } + if s.Branches[i].PullRequest == nil { + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Title: pr.Title, + } + } } } @@ -138,10 +152,14 @@ func runPush(cfg *config.Config, opts *pushOptions) error { fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n") fmt.Fprintf(cfg.Err, " grouped into a Stack.\n") - // Update head SHAs - for i, b := range s.Branches { - if sha, err := git.HeadSHA(b.Branch); err == nil { - s.Branches[i].Head = sha + // Update base commit hashes + for i := range s.Branches { + parent := s.Trunk.Branch + if i > 0 { + parent = s.Branches[i-1].Branch + } + if base, err := git.HeadSHA(parent); err == nil { + s.Branches[i].Base = base } } diff --git a/cmd/rebase.go b/cmd/rebase.go index cfd07bb..b770575 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -200,8 +200,12 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { _ = git.CheckoutBranch(currentBranch) for i := range s.Branches { - sha, _ := git.HeadSHA(s.Branches[i].Branch) - s.Branches[i].Head = sha + parent := s.Trunk.Branch + if i > 0 { + parent = s.Branches[i-1].Branch + } + base, _ := git.HeadSHA(parent) + s.Branches[i].Base = base } _ = stack.Save(gitDir, sf) @@ -312,8 +316,12 @@ func continueRebase(cfg *config.Config, gitDir string) error { _ = git.CheckoutBranch(state.OriginalBranch) for i := range s.Branches { - sha, _ := git.HeadSHA(s.Branches[i].Branch) - s.Branches[i].Head = sha + parent := s.Trunk.Branch + if i > 0 { + parent = s.Branches[i-1].Branch + } + base, _ := git.HeadSHA(parent) + s.Branches[i].Base = base } _ = stack.Save(gitDir, sf) diff --git a/internal/git/git.go b/internal/git/git.go index 24cd997..59a6edb 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -207,6 +207,11 @@ func HeadSHA(ref string) (string, error) { return run("rev-parse", ref) } +// MergeBase returns the best common ancestor commit between two refs. +func MergeBase(a, b string) (string, error) { + return run("merge-base", a, b) +} + // Log returns recent commits for the given branch. func Log(ref string, maxCount int) ([]CommitInfo, error) { format := "%H\t%s\t%at" diff --git a/internal/stack/stack.go b/internal/stack/stack.go index ea0b699..96df76b 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -13,14 +13,30 @@ const ( stackFileName = "gh-stack" ) -// BranchRef represents a branch and its HEAD commit. +// PullRequestRef holds relatively immutable metadata about an associated PR. +type PullRequestRef struct { + Number int `json:"number"` + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` +} + +// BranchRef represents a branch and its associated commit hash. +// For the trunk, Head stores the HEAD commit SHA. +// For stacked branches, Base stores the merge-base commit SHA +// (the last common commit before divergence from the parent branch). type BranchRef struct { - Branch string `json:"branch"` - Head string `json:"head"` + Branch string `json:"branch"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + PullRequest *PullRequestRef `json:"pullRequest,omitempty"` } // Stack represents a single stack of branches. type Stack struct { + ID string `json:"id,omitempty"` + State string `json:"state,omitempty"` + Open bool `json:"open,omitempty"` Trunk BranchRef `json:"trunk"` Branches []BranchRef `json:"branches"` } @@ -144,6 +160,7 @@ func Load(gitDir string) (*StackFile, error) { if err := json.Unmarshal(data, &sf); err != nil { return nil, fmt.Errorf("parsing stack file: %w", err) } + return &sf, nil } From 25ff8a15c72176ff584e5b7e3aa2fb6f3b5e2b65 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 17:08:39 -0400 Subject: [PATCH 14/78] schema for storage format --- internal/stack/schema.json | 100 +++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/stack/schema.json diff --git a/internal/stack/schema.json b/internal/stack/schema.json new file mode 100644 index 0000000..5e24ed9 --- /dev/null +++ b/internal/stack/schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "gh-stack file", + "description": "Schema for the .git/gh-stack file that stores the state of all stacks in a repository.", + "type": "object", + "required": ["schemaVersion", "stacks"], + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1, + "description": "Schema version for forward compatibility." + }, + "repository": { + "type": "string", + "description": "The owner/name slug of the repository (e.g. 'github/gh-stack')." + }, + "stacks": { + "type": "array", + "description": "All stacks tracked in this repository.", + "items": { "$ref": "#/$defs/stack" } + } + }, + "$defs": { + "stack": { + "type": "object", + "description": "A single stack of branches.", + "required": ["trunk", "branches"], + "properties": { + "id": { + "type": "string", + "description": "Identifier for this stack, populated from the API when available." + }, + "state": { + "type": "string", + "description": "State of the stack from the API (e.g. 'open', 'merged')." + }, + "open": { + "type": "boolean", + "description": "Whether the stack is open or closed." + }, + "trunk": { + "$ref": "#/$defs/branchRef", + "description": "The trunk (base) branch of the stack." + }, + "branches": { + "type": "array", + "description": "Ordered list of branches in the stack, from bottom to top.", + "items": { "$ref": "#/$defs/branchRef" } + } + } + }, + "branchRef": { + "type": "object", + "description": "A reference to a branch and its associated commit hash. For the trunk, 'head' stores the HEAD commit. For stacked branches, 'base' stores the merge-base commit (the last common commit before divergence from the parent branch).", + "required": ["branch"], + "properties": { + "branch": { + "type": "string", + "description": "The branch name." + }, + "head": { + "type": "string", + "description": "The HEAD commit SHA of this branch. Used for the trunk branch." + }, + "base": { + "type": "string", + "description": "The merge-base commit SHA — the last commit before this branch diverged from its parent. Used for stacked branches." + }, + "pullRequest": { + "$ref": "#/$defs/pullRequestRef", + "description": "Associated pull request information, if a PR exists for this branch." + } + } + }, + "pullRequestRef": { + "type": "object", + "description": "A snapshot of relatively immutable pull request metadata.", + "required": ["number"], + "properties": { + "number": { + "type": "integer", + "description": "The PR number." + }, + "id": { + "type": "string", + "description": "The PR node ID (GraphQL ID)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Direct URL to the pull request." + }, + "title": { + "type": "string", + "description": "The PR title at the time it was recorded." + } + } + } + } +} From cf62538481b75dd6b6d8b5199883cf3743052efb Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 19:08:15 -0400 Subject: [PATCH 15/78] sync state of prs in stack --- cmd/add.go | 2 +- cmd/init.go | 4 +++ cmd/navigate.go | 2 +- cmd/push.go | 7 ++-- cmd/rebase.go | 10 ++++-- cmd/unstack.go | 2 +- internal/stack/resolve.go => cmd/utils.go | 37 ++++++++++++++++++-- cmd/view.go | 6 +++- internal/github/github.go | 41 +++++++++++++++++++++++ internal/stack/schema.json | 22 ++++-------- internal/stack/stack.go | 4 +-- 11 files changed, 106 insertions(+), 31 deletions(-) rename internal/stack/resolve.go => cmd/utils.go (62%) diff --git a/cmd/add.go b/cmd/add.go index 88fe269..b47d62c 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -40,7 +40,7 @@ func runAdd(cfg *config.Config, args []string) error { return nil } - s, err := sf.ResolveStack(currentBranch, cfg) + s, err := resolveStack(sf, currentBranch, cfg) if err != nil { cfg.Errorf("%s", err) return nil diff --git a/cmd/init.go b/cmd/init.go index c82cf33..3a85002 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -186,6 +186,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { } sf.AddStack(newStack) + + // Sync PR state for adopted branches + syncStackPRs(cfg, &sf.Stacks[len(sf.Stacks)-1]) + if err := stack.Save(gitDir, sf); err != nil { return err } diff --git a/cmd/navigate.go b/cmd/navigate.go index b7cc04e..c3493f1 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -170,7 +170,7 @@ func loadCurrentStack(cfg *config.Config) (*stack.Stack, string, error) { return nil, "", fmt.Errorf("%s", errMsg) } - s, err := sf.ResolveStack(currentBranch, cfg) + s, err := resolveStack(sf, currentBranch, cfg) if err != nil { cfg.Errorf("%s", err) return nil, "", err diff --git a/cmd/push.go b/cmd/push.go index 1d4ca01..0e88e0c 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -52,7 +52,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } - s, err := sf.ResolveStack(currentBranch, cfg) + s, err := resolveStack(sf, currentBranch, cfg) if err != nil { cfg.Errorf("%s", err) return nil @@ -118,7 +118,6 @@ func runPush(cfg *config.Config, opts *pushOptions) error { Number: newPR.Number, ID: newPR.ID, URL: newPR.URL, - Title: newPR.Title, } } else { // Update base if needed @@ -136,7 +135,6 @@ func runPush(cfg *config.Config, opts *pushOptions) error { Number: pr.Number, ID: pr.ID, URL: pr.URL, - Title: pr.Title, } } } @@ -152,7 +150,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n") fmt.Fprintf(cfg.Err, " grouped into a Stack.\n") - // Update base commit hashes + // Update base commit hashes and sync PR state for i := range s.Branches { parent := s.Trunk.Branch if i > 0 { @@ -162,6 +160,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { s.Branches[i].Base = base } } + syncStackPRs(cfg, s) if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) diff --git a/cmd/rebase.go b/cmd/rebase.go index b770575..5729ac9 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -91,7 +91,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } - s, err := sf.ResolveStack(currentBranch, cfg) + s, err := resolveStack(sf, currentBranch, cfg) if err != nil { cfg.Errorf("%s", err) return nil @@ -207,6 +207,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { base, _ := git.HeadSHA(parent) s.Branches[i].Base = base } + + syncStackPRs(cfg, s) + _ = stack.Save(gitDir, sf) rangeDesc := "All branches in stack" @@ -238,7 +241,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { // Use the saved original branch to find the stack, since git may be in // a detached HEAD state during an active rebase. - s, err := sf.ResolveStack(state.OriginalBranch, cfg) + s, err := resolveStack(sf, state.OriginalBranch, cfg) if err != nil { return err } @@ -323,6 +326,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { base, _ := git.HeadSHA(parent) s.Branches[i].Base = base } + + syncStackPRs(cfg, s) + _ = stack.Save(gitDir, sf) cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch) diff --git a/cmd/unstack.go b/cmd/unstack.go index de98ed2..0dee3d3 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -55,7 +55,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { } } - s, err := sf.ResolveStack(target, cfg) + s, err := resolveStack(sf, target, cfg) if err != nil { cfg.Errorf("%s", err) return nil diff --git a/internal/stack/resolve.go b/cmd/utils.go similarity index 62% rename from internal/stack/resolve.go rename to cmd/utils.go index 73850e7..b87a19a 100644 --- a/internal/stack/resolve.go +++ b/cmd/utils.go @@ -1,4 +1,4 @@ -package stack +package cmd import ( "fmt" @@ -7,15 +7,16 @@ import ( "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" ) -// ResolveStack finds the stack for the given branch, handling ambiguity when +// resolveStack finds the stack for the given branch, handling ambiguity when // a branch (typically a trunk) belongs to multiple stacks. If exactly one // stack matches, it is returned directly. If multiple stacks match, the user // is prompted to select one and the working tree is switched to the top branch // of the selected stack. Returns nil with no error if no stack contains the // branch. -func (sf *StackFile) ResolveStack(branch string, cfg *config.Config) (*Stack, error) { +func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stack.Stack, error) { stacks := sf.FindAllStacksForBranch(branch) switch len(stacks) { @@ -56,3 +57,33 @@ func (sf *StackFile) ResolveStack(branch string, cfg *config.Config) (*Stack, er return s, nil } + +// syncStackPRs discovers and updates pull request metadata for branches in a stack. +// For each branch, it queries GitHub for the most recent PR and updates the +// PullRequestRef including merge status. Branches with already-merged PRs are skipped. +func syncStackPRs(cfg *config.Config, s *stack.Stack) { + client, err := cfg.GitHubClient() + if err != nil { + return + } + + for i := range s.Branches { + b := &s.Branches[i] + + if b.PullRequest != nil && b.PullRequest.Merged { + continue + } + + pr, err := client.FindAnyPRForBranch(b.Branch) + if err != nil || pr == nil { + continue + } + + b.PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Merged: pr.Merged, + } + } +} diff --git a/cmd/view.go b/cmd/view.go index 90fc8ad..4dd84fd 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -56,7 +56,7 @@ func runView(cfg *config.Config, opts *viewOptions) error { return nil } - s, err := sf.ResolveStack(currentBranch, cfg) + s, err := resolveStack(sf, currentBranch, cfg) if err != nil { cfg.Errorf("%s", err) return nil @@ -74,6 +74,10 @@ func runView(cfg *config.Config, opts *viewOptions) error { return nil } + // Sync PR state + syncStackPRs(cfg, s) + _ = stack.Save(gitDir, sf) + if opts.web { return viewWeb(cfg, s) } diff --git a/internal/github/github.go b/internal/github/github.go index 563634f..f73b82e 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -17,6 +17,7 @@ type PullRequest struct { HeadRefName string BaseRefName string IsDraft bool + Merged bool } // Client wraps GitHub API operations. @@ -82,6 +83,46 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { HeadRefName: n.HeadRefName, BaseRefName: n.BaseRefName, IsDraft: n.IsDraft, + Merged: n.Merged, + }, nil +} + +// FindAnyPRForBranch finds the most recent PR by head branch name regardless of state. +func (c *Client) FindAnyPRForBranch(branch string) (*PullRequest, error) { + var query struct { + Repository struct { + PullRequests struct { + Nodes []PullRequest + } `graphql:"pullRequests(headRefName: $head, last: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "head": graphql.String(branch), + } + + if err := c.gql.Query("FindAnyPRForBranch", &query, variables); err != nil { + return nil, fmt.Errorf("querying PRs: %w", err) + } + + nodes := query.Repository.PullRequests.Nodes + if len(nodes) == 0 { + return nil, nil + } + + n := nodes[0] + return &PullRequest{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + HeadRefName: n.HeadRefName, + BaseRefName: n.BaseRefName, + IsDraft: n.IsDraft, + Merged: n.Merged, }, nil } diff --git a/internal/stack/schema.json b/internal/stack/schema.json index 5e24ed9..1564936 100644 --- a/internal/stack/schema.json +++ b/internal/stack/schema.json @@ -12,7 +12,7 @@ }, "repository": { "type": "string", - "description": "The owner/name slug of the repository (e.g. 'github/gh-stack')." + "description": "The host:owner/name of the repository (e.g. 'github.com:github/gh-stack')." }, "stacks": { "type": "array", @@ -30,14 +30,6 @@ "type": "string", "description": "Identifier for this stack, populated from the API when available." }, - "state": { - "type": "string", - "description": "State of the stack from the API (e.g. 'open', 'merged')." - }, - "open": { - "type": "boolean", - "description": "Whether the stack is open or closed." - }, "trunk": { "$ref": "#/$defs/branchRef", "description": "The trunk (base) branch of the stack." @@ -74,25 +66,25 @@ }, "pullRequestRef": { "type": "object", - "description": "A snapshot of relatively immutable pull request metadata.", + "description": "A snapshot of pull request metadata.", "required": ["number"], "properties": { "number": { "type": "integer", - "description": "The PR number." + "description": "The PR number, scoped to the repository." }, "id": { "type": "string", - "description": "The PR node ID (GraphQL ID)." + "description": "The PR global node ID." }, "url": { "type": "string", "format": "uri", "description": "Direct URL to the pull request." }, - "title": { - "type": "string", - "description": "The PR title at the time it was recorded." + "merged": { + "type": "boolean", + "description": "Whether the pull request has been merged." } } } diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 96df76b..3d5fd02 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -18,7 +18,7 @@ type PullRequestRef struct { Number int `json:"number"` ID string `json:"id,omitempty"` URL string `json:"url,omitempty"` - Title string `json:"title,omitempty"` + Merged bool `json:"merged,omitempty"` } // BranchRef represents a branch and its associated commit hash. @@ -35,8 +35,6 @@ type BranchRef struct { // Stack represents a single stack of branches. type Stack struct { ID string `json:"id,omitempty"` - State string `json:"state,omitempty"` - Open bool `json:"open,omitempty"` Trunk BranchRef `json:"trunk"` Branches []BranchRef `json:"branches"` } From 2661ee705f51485f61a1717b025d7220eca91359 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 10 Mar 2026 19:24:54 -0400 Subject: [PATCH 16/78] sync command to fetch, rebase, and push --- cmd/root.go | 1 + cmd/sync.go | 260 ++++++++++++++++++++++++++++++++++++++++++++ internal/git/git.go | 24 ++++ 3 files changed, 285 insertions(+) create mode 100644 cmd/sync.go diff --git a/cmd/root.go b/cmd/root.go index 3feb4a7..fe1e4a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,7 @@ func RootCmd() *cobra.Command { // Remote operations root.AddCommand(CheckoutCmd(cfg)) root.AddCommand(PushCmd(cfg)) + root.AddCommand(SyncCmd(cfg)) root.AddCommand(UnstackCmd(cfg)) root.AddCommand(MergeCmd(cfg)) diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..8755df3 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,260 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type syncOptions struct{} + +func SyncCmd(cfg *config.Config) *cobra.Command { + opts := &syncOptions{} + + cmd := &cobra.Command{ + Use: "sync", + Short: "Sync the current stack with the remote", + Long: `Fetch, rebase, push, and sync PR state for the current stack. + +This command performs a safe, non-interactive synchronization: + + 1. Fetches the latest changes from origin + 2. Fast-forwards the trunk branch to match the remote + 3. Cascade-rebases stack branches onto their updated parents + 4. Pushes all branches (using --force-with-lease) + 5. Syncs PR state from GitHub + +If a rebase conflict is detected, all branches are restored to their +original state and you are advised to run "gh stack rebase" to resolve +conflicts interactively.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSync(cfg, opts) + }, + } + + return cmd +} + +func runSync(cfg *config.Config, _ *syncOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + + s, err := resolveStack(sf, currentBranch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil + } + if s == nil { + cfg.Errorf("current branch %q is not part of a stack", currentBranch) + return nil + } + + // Re-read current branch in case disambiguation caused a checkout + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil + } + + // --- Step 1: Fetch --- + cfg.Printf("Fetching origin ...") + if err := git.Fetch("origin"); err != nil { + cfg.Warningf("Failed to fetch origin: %v", err) + } else { + cfg.Successf("Fetched latest changes") + } + + // --- Step 2: Fast-forward trunk --- + trunk := s.Trunk.Branch + trunkUpdated := false + + localSHA, localErr := git.HeadSHA(trunk) + remoteSHA, remoteErr := git.HeadSHA("origin/" + trunk) + + if localErr != nil || remoteErr != nil { + cfg.Warningf("Could not compare trunk %s with remote — skipping trunk update", trunk) + } else if localSHA == remoteSHA { + cfg.Successf("Trunk %s is already up to date", trunk) + } else { + isAncestor, err := git.IsAncestor(localSHA, remoteSHA) + if err != nil { + cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) + } else if !isAncestor { + cfg.Warningf("Trunk %s has diverged from origin — skipping trunk update", trunk) + cfg.Printf(" Local and remote %s have diverged. Resolve manually.", trunk) + } else { + // Fast-forward the trunk branch + if currentBranch == trunk { + // Can't update ref of checked-out branch; merge instead + if err := ffMerge(trunk); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + trunkUpdated = true + } + } else { + if err := updateBranchRef(trunk, remoteSHA); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + trunkUpdated = true + } + } + } + } + + // --- Step 3: Cascade rebase (only if trunk moved) --- + rebased := false + if trunkUpdated { + cfg.Printf("") + cfg.Printf("Rebasing stack ...") + + // Save original refs so we can restore on conflict + originalRefs := make(map[string]string) + for _, b := range s.Branches { + sha, _ := git.HeadSHA(b.Branch) + originalRefs[b.Branch] = sha + } + + conflicted := false + for i, br := range s.Branches { + var base string + if i == 0 { + base = trunk + } else { + base = s.Branches[i-1].Branch + } + + if err := git.CheckoutBranch(br.Branch); err != nil { + cfg.Errorf("Failed to checkout %s: %v", br.Branch, err) + conflicted = true + break + } + + if err := git.Rebase(base); err != nil { + // Conflict detected — abort and restore everything + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + + // Restore all branches to original state + for branch, sha := range originalRefs { + _ = git.CheckoutBranch(branch) + _ = git.ResetHard(sha) + } + + _ = git.CheckoutBranch(currentBranch) + + cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base) + cfg.Printf(" All branches restored to their original state.") + cfg.Printf(" Run %s to resolve conflicts interactively.", + cfg.ColorCyan("gh stack rebase")) + conflicted = true + break + } + + cfg.Successf("Rebased %s onto %s", br.Branch, base) + } + + if !conflicted { + rebased = true + _ = git.CheckoutBranch(currentBranch) + } + } + + // --- Step 4: Push --- + cfg.Printf("") + branches := make([]string, len(s.Branches)) + for i, b := range s.Branches { + branches[i] = b.Branch + } + + // After rebase, force-with-lease is required (history rewritten). + // Without rebase, try a normal push first. + force := rebased + cfg.Printf("Pushing branches ...") + if err := git.Push("origin", branches, force, false); err != nil { + if !force { + cfg.Warningf("Push failed — branches may need force push after rebase") + cfg.Printf(" Run %s to push with --force-with-lease.", + cfg.ColorCyan("gh stack push -f")) + } else { + cfg.Warningf("Push failed: %v", err) + cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push -f")) + } + } else { + cfg.Successf("Pushed %d branches", len(branches)) + } + + // --- Step 5: Sync PR state --- + cfg.Printf("") + cfg.Printf("Syncing PRs ...") + syncStackPRs(cfg, s) + + // Report PR status for each branch + for _, b := range s.Branches { + if b.PullRequest != nil { + state := "Open" + if b.PullRequest.Merged { + state = "Merged" + } + cfg.Successf("PR #%d (%s) — %s", b.PullRequest.Number, b.Branch, state) + } else { + cfg.Warningf("%s has no PR", b.Branch) + } + } + + // --- Step 6: Update base SHAs and save --- + for i := range s.Branches { + parent := trunk + if i > 0 { + parent = s.Branches[i-1].Branch + } + if base, err := git.HeadSHA(parent); err == nil { + s.Branches[i].Base = base + } + } + + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return nil + } + + cfg.Printf("") + cfg.Successf("Stack synced") + return nil +} + +// ffMerge fast-forwards the currently checked-out branch to match origin. +func ffMerge(branch string) error { + return git.MergeFF("origin/" + branch) +} + +// updateBranchRef updates a branch ref to point to a new SHA (for branches not checked out). +func updateBranchRef(branch, sha string) error { + return git.UpdateBranchRef(branch, sha) +} + +// short returns the first 7 characters of a SHA. +func short(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/internal/git/git.go b/internal/git/git.go index 59a6edb..a55b9d9 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -202,6 +202,20 @@ func FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { return info, nil } +// IsAncestor returns whether ancestor is an ancestor of descendant. +// This is useful to check if a fast-forward merge is possible. +func IsAncestor(ancestor, descendant string) (bool, error) { + err := runSilent("merge-base", "--is-ancestor", ancestor, descendant) + if err == nil { + return true, nil + } + // Exit code 1 means "not an ancestor", which is not an error condition. + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err +} + // HeadSHA returns the full SHA of the given ref. func HeadSHA(ref string) (string, error) { return run("rev-parse", ref) @@ -262,3 +276,13 @@ func ResetHard(ref string) error { func SetUpstreamTracking(branch, remote string) error { return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) } + +// MergeFF fast-forwards the currently checked-out branch using a merge. +func MergeFF(target string) error { + return runSilent("merge", "--ff-only", target) +} + +// UpdateBranchRef moves a branch pointer to a new commit (for branches not currently checked out). +func UpdateBranchRef(branch, sha string) error { + return runSilent("branch", "-f", branch, sha) +} From f1421adfb4faa68f296a7e400da3d7257724e296 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 00:01:14 -0400 Subject: [PATCH 17/78] rebase onto for handling squashed prs --- cmd/push.go | 3 + cmd/rebase.go | 248 ++++++++++++++++++++++++++++++++++---------- cmd/sync.go | 102 ++++++++++++++---- internal/git/git.go | 15 ++- 4 files changed, 288 insertions(+), 80 deletions(-) diff --git a/cmd/push.go b/cmd/push.go index 0e88e0c..3b1ddac 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -159,6 +159,9 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if base, err := git.HeadSHA(parent); err == nil { s.Branches[i].Base = base } + if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { + s.Branches[i].Head = head + } } syncStackPRs(cfg, s) diff --git a/cmd/rebase.go b/cmd/rebase.go index 5729ac9..69cb43a 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -26,6 +26,8 @@ type rebaseState struct { RemainingBranches []string `json:"remainingBranches"` OriginalBranch string `json:"originalBranch"` OriginalRefs map[string]string `json:"originalRefs"` + UseOnto bool `json:"useOnto,omitempty"` + OntoOldBase string `json:"ontoOldBase,omitempty"` } const rebaseStateFile = "gh-stack-rebase-state" @@ -146,12 +148,19 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { cfg.Printf("Rebasing branches in order, starting from %s to %s", branchesToRebase[0].Branch, branchesToRebase[len(branchesToRebase)-1].Branch) + // Sync PR state before rebase so we can detect merged PRs. + syncStackPRs(cfg, s) + originalRefs := make(map[string]string) for _, b := range s.Branches { sha, _ := git.HeadSHA(b.Branch) originalRefs[b.Branch] = sha } + // Track --onto rebase state for squash-merged branches. + needsOnto := false + var ontoOldBase string + for i, br := range branchesToRebase { var base string absIdx := startIdx + i @@ -161,51 +170,118 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { base = s.Branches[absIdx-1].Branch } - cfg.Printf("Rebasing %s onto %s ...", br.Branch, base) - - if err := git.CheckoutBranch(br.Branch); err != nil { - return fmt.Errorf("checking out %s: %w", br.Branch, err) + // Skip branches whose PRs have already been merged (e.g. via squash). + // Record state so subsequent branches can use --onto rebase. + if br.PullRequest != nil && br.PullRequest.Merged { + ontoOldBase = originalRefs[br.Branch] + needsOnto = true + cfg.Successf("Skipping %s (PR #%d merged)", br.Branch, br.PullRequest.Number) + continue } - if err := git.Rebase(base); err != nil { - cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base) + if needsOnto { + // Find the proper --onto target: the first non-merged ancestor, or trunk. + newBase := s.Trunk.Branch + for j := absIdx - 1; j >= 0; j-- { + b := s.Branches[j] + if b.PullRequest == nil || !b.PullRequest.Merged { + newBase = b.Branch + break + } + } + + cfg.Printf("Rebasing %s onto %s (squash-merge detected) ...", br.Branch, newBase) + + if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { + cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, newBase) + + remaining := make([]string, 0) + for j := i + 1; j < len(branchesToRebase); j++ { + remaining = append(remaining, branchesToRebase[j].Branch) + } + + state := &rebaseState{ + CurrentBranchIndex: absIdx, + ConflictBranch: br.Branch, + RemainingBranches: remaining, + OriginalBranch: currentBranch, + OriginalRefs: originalRefs, + UseOnto: true, + OntoOldBase: originalRefs[br.Branch], + } + saveRebaseState(gitDir, state) + + printConflictDetails(cfg, newBase) + cfg.Printf("") - remaining := make([]string, 0) - for j := i + 1; j < len(branchesToRebase); j++ { - remaining = append(remaining, branchesToRebase[j].Branch) + cfg.Printf("Resolve conflicts on %s, then run %s", + br.Branch, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with %s", + cfg.ColorCyan("gh stack rebase --abort")) + return fmt.Errorf("rebase conflict on %s", br.Branch) } - state := &rebaseState{ - CurrentBranchIndex: absIdx, - ConflictBranch: br.Branch, - RemainingBranches: remaining, - OriginalBranch: currentBranch, - OriginalRefs: originalRefs, + cfg.Successf("Rebasing %s onto %s", br.Branch, newBase) + // Keep --onto mode; update old base for the next branch. + ontoOldBase = originalRefs[br.Branch] + } else { + cfg.Printf("Rebasing %s onto %s ...", br.Branch, base) + + if err := git.CheckoutBranch(br.Branch); err != nil { + return fmt.Errorf("checking out %s: %w", br.Branch, err) } - saveRebaseState(gitDir, state) - printConflictDetails(cfg, base) - cfg.Printf("") + if err := git.Rebase(base); err != nil { + cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base) - cfg.Printf("Resolve conflicts on %s, then run %s", - br.Branch, cfg.ColorCyan("gh stack rebase --continue")) - cfg.Printf("Or abort this operation with %s", - cfg.ColorCyan("gh stack rebase --abort")) - return fmt.Errorf("rebase conflict on %s", br.Branch) - } + remaining := make([]string, 0) + for j := i + 1; j < len(branchesToRebase); j++ { + remaining = append(remaining, branchesToRebase[j].Branch) + } - cfg.Successf("Rebasing %s onto %s", br.Branch, base) + state := &rebaseState{ + CurrentBranchIndex: absIdx, + ConflictBranch: br.Branch, + RemainingBranches: remaining, + OriginalBranch: currentBranch, + OriginalRefs: originalRefs, + } + saveRebaseState(gitDir, state) + + printConflictDetails(cfg, base) + cfg.Printf("") + + cfg.Printf("Resolve conflicts on %s, then run %s", + br.Branch, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with %s", + cfg.ColorCyan("gh stack rebase --abort")) + return fmt.Errorf("rebase conflict on %s", br.Branch) + } + + cfg.Successf("Rebasing %s onto %s", br.Branch, base) + } } _ = git.CheckoutBranch(currentBranch) for i := range s.Branches { + // Skip merged branches when updating base SHAs. + if s.Branches[i].PullRequest != nil && s.Branches[i].PullRequest.Merged { + continue + } + // Find the first non-merged ancestor, or trunk. parent := s.Trunk.Branch - if i > 0 { - parent = s.Branches[i-1].Branch + for j := i - 1; j >= 0; j-- { + if s.Branches[j].PullRequest == nil || !s.Branches[j].PullRequest.Merged { + parent = s.Branches[j].Branch + break + } } base, _ := git.HeadSHA(parent) s.Branches[i].Base = base + if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { + s.Branches[i].Head = head + } } syncStackPRs(cfg, s) @@ -275,6 +351,16 @@ func continueRebase(cfg *config.Config, gitDir string) error { for _, branchName := range state.RemainingBranches { idx := s.IndexOf(branchName) + + // Skip branches whose PRs have already been merged. + br := s.Branches[idx] + if br.PullRequest != nil && br.PullRequest.Merged { + state.OntoOldBase = state.OriginalRefs[branchName] + state.UseOnto = true + cfg.Successf("Skipping %s (PR #%d merged)", branchName, br.PullRequest.Number) + continue + } + var base string if idx == 0 { base = s.Trunk.Branch @@ -282,49 +368,101 @@ func continueRebase(cfg *config.Config, gitDir string) error { base = s.Branches[idx-1].Branch } - cfg.Printf("Rebasing %s onto %s ...", branchName, base) + if state.UseOnto { + // Find the proper --onto target: first non-merged ancestor, or trunk. + newBase := s.Trunk.Branch + for j := idx - 1; j >= 0; j-- { + b := s.Branches[j] + if b.PullRequest == nil || !b.PullRequest.Merged { + newBase = b.Branch + break + } + } - if err := git.CheckoutBranch(branchName); err != nil { - cfg.Errorf("checking out %s: %s", branchName, err) - return nil - } + cfg.Printf("Rebasing %s onto %s (squash-merge detected) ...", branchName, newBase) - if err := git.Rebase(base); err != nil { - remainIdx := -1 - for ri, rb := range state.RemainingBranches { - if rb == branchName { - remainIdx = ri - break + if err := git.RebaseOnto(newBase, state.OntoOldBase, branchName); err != nil { + remainIdx := -1 + for ri, rb := range state.RemainingBranches { + if rb == branchName { + remainIdx = ri + break + } } + state.RemainingBranches = state.RemainingBranches[remainIdx+1:] + state.CurrentBranchIndex = idx + state.ConflictBranch = branchName + state.OntoOldBase = state.OriginalRefs[branchName] + saveRebaseState(gitDir, state) + + cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, newBase) + printConflictDetails(cfg, newBase) + cfg.Printf("") + cfg.Printf("Resolve conflicts on %s, then run %s", + branchName, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with %s", + cfg.ColorCyan("gh stack rebase --abort")) + return fmt.Errorf("rebase conflict on %s", branchName) } - state.RemainingBranches = state.RemainingBranches[remainIdx+1:] - state.CurrentBranchIndex = idx - state.ConflictBranch = branchName - saveRebaseState(gitDir, state) - - cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, base) - printConflictDetails(cfg, base) - cfg.Printf("") - cfg.Printf("Resolve conflicts on %s, then run %s", - branchName, cfg.ColorCyan("gh stack rebase --continue")) - cfg.Printf("Or abort this operation with %s", - cfg.ColorCyan("gh stack rebase --abort")) - return fmt.Errorf("rebase conflict on %s", branchName) - } - cfg.Successf("Rebasing %s onto %s", branchName, base) + cfg.Successf("Rebasing %s onto %s", branchName, newBase) + state.OntoOldBase = state.OriginalRefs[branchName] + } else { + cfg.Printf("Rebasing %s onto %s ...", branchName, base) + + if err := git.CheckoutBranch(branchName); err != nil { + cfg.Errorf("checking out %s: %s", branchName, err) + return nil + } + + if err := git.Rebase(base); err != nil { + remainIdx := -1 + for ri, rb := range state.RemainingBranches { + if rb == branchName { + remainIdx = ri + break + } + } + state.RemainingBranches = state.RemainingBranches[remainIdx+1:] + state.CurrentBranchIndex = idx + state.ConflictBranch = branchName + saveRebaseState(gitDir, state) + + cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, base) + printConflictDetails(cfg, base) + cfg.Printf("") + cfg.Printf("Resolve conflicts on %s, then run %s", + branchName, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with %s", + cfg.ColorCyan("gh stack rebase --abort")) + return fmt.Errorf("rebase conflict on %s", branchName) + } + + cfg.Successf("Rebasing %s onto %s", branchName, base) + } } clearRebaseState(gitDir) _ = git.CheckoutBranch(state.OriginalBranch) for i := range s.Branches { + // Skip merged branches when updating base SHAs. + if s.Branches[i].PullRequest != nil && s.Branches[i].PullRequest.Merged { + continue + } + // Find the first non-merged ancestor, or trunk. parent := s.Trunk.Branch - if i > 0 { - parent = s.Branches[i-1].Branch + for j := i - 1; j >= 0; j-- { + if s.Branches[j].PullRequest == nil || !s.Branches[j].PullRequest.Merged { + parent = s.Branches[j].Branch + break + } } base, _ := git.HeadSHA(parent) s.Branches[i].Base = base + if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { + s.Branches[i].Head = head + } } syncStackPRs(cfg, s) diff --git a/cmd/sync.go b/cmd/sync.go index 8755df3..4009f0b 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -125,6 +125,9 @@ func runSync(cfg *config.Config, _ *syncOptions) error { cfg.Printf("") cfg.Printf("Rebasing stack ...") + // Sync PR state to detect merged PRs before rebasing. + syncStackPRs(cfg, s) + // Save original refs so we can restore on conflict originalRefs := make(map[string]string) for _, b := range s.Branches { @@ -132,6 +135,9 @@ func runSync(cfg *config.Config, _ *syncOptions) error { originalRefs[b.Branch] = sha } + needsOnto := false + var ontoOldBase string + conflicted := false for i, br := range s.Branches { var base string @@ -141,35 +147,74 @@ func runSync(cfg *config.Config, _ *syncOptions) error { base = s.Branches[i-1].Branch } - if err := git.CheckoutBranch(br.Branch); err != nil { - cfg.Errorf("Failed to checkout %s: %v", br.Branch, err) - conflicted = true - break + // Skip branches whose PRs have already been merged. + if br.PullRequest != nil && br.PullRequest.Merged { + ontoOldBase = originalRefs[br.Branch] + needsOnto = true + cfg.Successf("Skipping %s (PR #%d merged)", br.Branch, br.PullRequest.Number) + continue } - if err := git.Rebase(base); err != nil { - // Conflict detected — abort and restore everything - if git.IsRebaseInProgress() { - _ = git.RebaseAbort() + if needsOnto { + // Find --onto target: first non-merged ancestor, or trunk. + newBase := trunk + for j := i - 1; j >= 0; j-- { + b := s.Branches[j] + if b.PullRequest == nil || !b.PullRequest.Merged { + newBase = b.Branch + break + } } - // Restore all branches to original state - for branch, sha := range originalRefs { - _ = git.CheckoutBranch(branch) - _ = git.ResetHard(sha) + if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { + // Conflict detected — abort and restore everything + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + for branch, sha := range originalRefs { + _ = git.CheckoutBranch(branch) + _ = git.ResetHard(sha) + } + _ = git.CheckoutBranch(currentBranch) + + cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, newBase) + cfg.Printf(" All branches restored to their original state.") + cfg.Printf(" Run %s to resolve conflicts interactively.", + cfg.ColorCyan("gh stack rebase")) + conflicted = true + break } - _ = git.CheckoutBranch(currentBranch) + cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) + ontoOldBase = originalRefs[br.Branch] + } else { + if err := git.CheckoutBranch(br.Branch); err != nil { + cfg.Errorf("Failed to checkout %s: %v", br.Branch, err) + conflicted = true + break + } - cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base) - cfg.Printf(" All branches restored to their original state.") - cfg.Printf(" Run %s to resolve conflicts interactively.", - cfg.ColorCyan("gh stack rebase")) - conflicted = true - break - } + if err := git.Rebase(base); err != nil { + // Conflict detected — abort and restore everything + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + for branch, sha := range originalRefs { + _ = git.CheckoutBranch(branch) + _ = git.ResetHard(sha) + } + _ = git.CheckoutBranch(currentBranch) + + cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base) + cfg.Printf(" All branches restored to their original state.") + cfg.Printf(" Run %s to resolve conflicts interactively.", + cfg.ColorCyan("gh stack rebase")) + conflicted = true + break + } - cfg.Successf("Rebased %s onto %s", br.Branch, base) + cfg.Successf("Rebased %s onto %s", br.Branch, base) + } } if !conflicted { @@ -222,13 +267,24 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // --- Step 6: Update base SHAs and save --- for i := range s.Branches { + // Skip merged branches when updating base SHAs. + if s.Branches[i].PullRequest != nil && s.Branches[i].PullRequest.Merged { + continue + } + // Find the first non-merged ancestor, or trunk. parent := trunk - if i > 0 { - parent = s.Branches[i-1].Branch + for j := i - 1; j >= 0; j-- { + if s.Branches[j].PullRequest == nil || !s.Branches[j].PullRequest.Merged { + parent = s.Branches[j].Branch + break + } } if base, err := git.HeadSHA(parent); err == nil { s.Branches[i].Base = base } + if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { + s.Branches[i].Head = head + } } if err := stack.Save(gitDir, sf); err != nil { diff --git a/internal/git/git.go b/internal/git/git.go index a55b9d9..4db3947 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -106,8 +106,19 @@ func Push(remote string, branches []string, force, atomic bool) error { } // Rebase rebases the current branch onto the given base. -func Rebase(onto string) error { - return runSilent("rebase", onto) +func Rebase(base string) error { + return runSilent("rebase", base) +} + +// RebaseOnto rebases a branch using the three-argument form: +// +// git rebase --onto +// +// This replays commits after oldBase from branch onto newBase. It is used +// when a prior branch was squash-merged and the normal rebase cannot detect +// which commits have already been applied. +func RebaseOnto(newBase, oldBase, branch string) error { + return runSilent("rebase", "--onto", newBase, oldBase, branch) } // RebaseContinue continues an in-progress rebase. From 03c4b922708bbc39e078b3de089a29d1c2453da5 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 01:29:49 -0400 Subject: [PATCH 18/78] enable git rerere --- cmd/init.go | 4 +++ cmd/rebase.go | 36 +++++++++++++++++++++------ cmd/sync.go | 21 ++++++++++++---- internal/git/git.go | 59 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 14 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 3a85002..f0fcad8 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -53,6 +53,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Determine trunk branch trunk := opts.base + + // Enable git rerere so conflict resolutions are remembered. + _ = git.EnableRerere() + if trunk == "" { trunk, err = git.DefaultBranch() if err != nil { diff --git a/cmd/rebase.go b/cmd/rebase.go index 69cb43a..89a7534 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -111,6 +111,10 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } cfg.Printf("Fetching origin ...") + + // Enable git rerere so conflict resolutions are remembered. + _ = git.EnableRerere() + if err := git.Fetch("origin"); err != nil { cfg.Warningf("Failed to fetch origin: %v", err) } else { @@ -227,11 +231,22 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } else { cfg.Printf("Rebasing %s onto %s ...", br.Branch, base) - if err := git.CheckoutBranch(br.Branch); err != nil { - return fmt.Errorf("checking out %s: %w", br.Branch, err) + var rebaseErr error + if absIdx > 0 { + // Use --onto to replay only this branch's unique commits. + // Without --onto, git may try to replay commits shared with + // the parent, causing duplicate-patch conflicts when the + // parent's rebase rewrote those commits. + rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch) + } else { + if err := git.CheckoutBranch(br.Branch); err != nil { + return fmt.Errorf("checking out %s: %w", br.Branch, err) + } + // Use regular rebase for the first branch. + rebaseErr = git.Rebase(base) } - if err := git.Rebase(base); err != nil { + if rebaseErr != nil { cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base) remaining := make([]string, 0) @@ -410,12 +425,19 @@ func continueRebase(cfg *config.Config, gitDir string) error { } else { cfg.Printf("Rebasing %s onto %s ...", branchName, base) - if err := git.CheckoutBranch(branchName); err != nil { - cfg.Errorf("checking out %s: %s", branchName, err) - return nil + var rebaseErr error + if idx > 0 { + // Use --onto to replay only this branch's unique commits. + rebaseErr = git.RebaseOnto(base, state.OriginalRefs[base], branchName) + } else { + if err := git.CheckoutBranch(branchName); err != nil { + cfg.Errorf("checking out %s: %s", branchName, err) + return nil + } + rebaseErr = git.Rebase(base) } - if err := git.Rebase(base); err != nil { + if rebaseErr != nil { remainIdx := -1 for ri, rb := range state.RemainingBranches { if rb == branchName { diff --git a/cmd/sync.go b/cmd/sync.go index 4009f0b..717d789 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -74,6 +74,10 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // --- Step 1: Fetch --- cfg.Printf("Fetching origin ...") + + // Enable git rerere so conflict resolutions are remembered. + _ = git.EnableRerere() + if err := git.Fetch("origin"); err != nil { cfg.Warningf("Failed to fetch origin: %v", err) } else { @@ -188,13 +192,20 @@ func runSync(cfg *config.Config, _ *syncOptions) error { cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) ontoOldBase = originalRefs[br.Branch] } else { - if err := git.CheckoutBranch(br.Branch); err != nil { - cfg.Errorf("Failed to checkout %s: %v", br.Branch, err) - conflicted = true - break + var rebaseErr error + if i > 0 { + // Use --onto to replay only this branch's unique commits. + rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch) + } else { + if err := git.CheckoutBranch(br.Branch); err != nil { + cfg.Errorf("Failed to checkout %s: %v", br.Branch, err) + conflicted = true + break + } + rebaseErr = git.Rebase(base) } - if err := git.Rebase(base); err != nil { + if rebaseErr != nil { // Conflict detected — abort and restore everything if git.IsRebaseInProgress() { _ = git.RebaseAbort() diff --git a/internal/git/git.go b/internal/git/git.go index 4db3947..c508235 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -106,8 +106,23 @@ func Push(remote string, branches []string, force, atomic bool) error { } // Rebase rebases the current branch onto the given base. +// If rerere resolves all conflicts automatically, the rebase continues +// without user intervention. func Rebase(base string) error { - return runSilent("rebase", base) + err := runSilent("rebase", base) + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +// EnableRerere enables git rerere (reuse recorded resolution) and +// rerere.autoupdate (auto-stage resolved files) for the repository. +func EnableRerere() error { + if err := runSilent("config", "rerere.enabled", "true"); err != nil { + return err + } + return runSilent("config", "rerere.autoupdate", "true") } // RebaseOnto rebases a branch using the three-argument form: @@ -117,19 +132,59 @@ func Rebase(base string) error { // This replays commits after oldBase from branch onto newBase. It is used // when a prior branch was squash-merged and the normal rebase cannot detect // which commits have already been applied. +// If rerere resolves all conflicts automatically, the rebase continues +// without user intervention. func RebaseOnto(newBase, oldBase, branch string) error { - return runSilent("rebase", "--onto", newBase, oldBase, branch) + err := runSilent("rebase", "--onto", newBase, oldBase, branch) + if err == nil { + return nil + } + return tryAutoResolveRebase(err) } // RebaseContinue continues an in-progress rebase. // It sets GIT_EDITOR=true to prevent git from opening an interactive editor // for the commit message, which would cause the command to hang. +// If rerere resolves subsequent conflicts automatically, the rebase continues +// without user intervention. func RebaseContinue() error { + err := rebaseContinueOnce() + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +// rebaseContinueOnce runs a single git rebase --continue without auto-resolve. +func rebaseContinueOnce() error { cmd := exec.Command("git", "rebase", "--continue") cmd.Env = append(os.Environ(), "GIT_EDITOR=true") return cmd.Run() } +// tryAutoResolveRebase checks whether rerere has resolved all conflicts +// from a failed rebase. If so, it auto-continues the rebase (potentially +// multiple times for multi-commit rebases). Returns originalErr if any +// conflicts remain that need manual resolution. +func tryAutoResolveRebase(originalErr error) error { + for i := 0; i < 1000; i++ { + if !IsRebaseInProgress() { + return nil + } + conflicts, _ := ConflictedFiles() + if len(conflicts) > 0 { + return originalErr + } + // Rerere resolved all conflicts — auto-continue. + if rebaseContinueOnce() == nil { + return nil + } + // Continue hit another conflicting commit; loop to check + // if rerere resolved that one too. + } + return originalErr +} + // RebaseAbort aborts an in-progress rebase. func RebaseAbort() error { return runSilent("rebase", "--abort") From 5937a751518fbc151cee1365392c35587e8f00cf Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 09:15:55 -0400 Subject: [PATCH 19/78] addressing review comments --- cmd/init.go | 7 +++-- cmd/navigate.go | 5 ++++ cmd/push.go | 20 +++++++-------- cmd/rebase.go | 54 +++++++++++++++++++++++++++++++-------- cmd/view.go | 10 +++++--- internal/config/config.go | 11 ++++---- internal/git/git.go | 5 ++-- internal/github/github.go | 18 ++++++------- 8 files changed, 87 insertions(+), 43 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index f0fcad8..048a46a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" @@ -124,7 +123,11 @@ func runInit(cfg *config.Config, opts *initOptions) error { branches = opts.branches } else { // Interactive mode - p := prompter.New(os.Stdin, os.Stdout, os.Stderr) + if !cfg.IsInteractive() { + cfg.Errorf("interactive input required; provide branch names or use --adopt") + return nil + } + p := prompter.New(cfg.In, cfg.Out, cfg.Err) if currentBranch != "" && currentBranch != trunk { // Already on a non-trunk branch — offer to use it diff --git a/cmd/navigate.go b/cmd/navigate.go index c3493f1..c50bc75 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -124,6 +124,11 @@ func runNavigateToEnd(cfg *config.Config, top bool) error { return nil } + if len(s.Branches) == 0 { + cfg.Errorf("stack has no branches") + return nil + } + var target string if top { target = s.Branches[len(s.Branches)-1].Branch diff --git a/cmd/push.go b/cmd/push.go index 3b1ddac..00c34fe 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -78,11 +78,11 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Push all branches for _, b := range s.Branches { if opts.dryRun { - cfg.Printf("Would push %s\n", b.Branch) + cfg.Printf("Would push %s", b.Branch) continue } - cfg.Printf("Pushing %s...\n", b.Branch) + cfg.Printf("Pushing %s...", b.Branch) if err := git.Push("origin", []string{b.Branch}, opts.force, false); err != nil { cfg.Errorf("failed to push %s: %s", b.Branch, err) return nil @@ -99,7 +99,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { pr, err := client.FindPRForBranch(b.Branch) if err != nil { - cfg.Warningf("failed to check PR for %s: %v\n", b.Branch, err) + cfg.Warningf("failed to check PR for %s: %v", b.Branch, err) continue } @@ -110,10 +110,10 @@ func runPush(cfg *config.Config, opts *pushOptions) error { newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) if createErr != nil { - cfg.Warningf("failed to create PR for %s: %v\n", b.Branch, createErr) + cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) continue } - cfg.Successf("Created PR #%d for %s\n", newPR.Number, b.Branch) + cfg.Successf("Created PR #%d for %s", newPR.Number, b.Branch) s.Branches[i].PullRequest = &stack.PullRequestRef{ Number: newPR.Number, ID: newPR.ID, @@ -123,12 +123,12 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Update base if needed if pr.BaseRefName != baseBranch { if err := client.UpdatePRBase(pr.ID, baseBranch); err != nil { - cfg.Warningf("failed to update PR #%d base: %v\n", pr.Number, err) + cfg.Warningf("failed to update PR #%d base: %v", pr.Number, err) } else { - cfg.Successf("Updated PR #%d base to %s\n", pr.Number, baseBranch) + cfg.Successf("Updated PR #%d base to %s", pr.Number, baseBranch) } } else { - cfg.Printf("PR #%d for %s is up to date\n", pr.Number, b.Branch) + cfg.Printf("PR #%d for %s is up to date", pr.Number, b.Branch) } if s.Branches[i].PullRequest == nil { s.Branches[i].PullRequest = &stack.PullRequestRef{ @@ -146,7 +146,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // or we can add a flag to the existing PR API to incrementally build the stack. // // For now, the PRs are pushed and created individually but are NOT linked as a formal stack on GitHub. - cfg.Warningf("Stacked PRs is not yet implemented — PRs were created individually.\n") + cfg.Warningf("Stacked PRs is not yet implemented — PRs were created individually.") fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n") fmt.Fprintf(cfg.Err, " grouped into a Stack.\n") @@ -170,6 +170,6 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } - cfg.Successf("Pushed and synced %d branches\n", len(s.Branches)) + cfg.Successf("Pushed and synced %d branches", len(s.Branches)) return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index 89a7534..6303e44 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -157,7 +157,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { originalRefs := make(map[string]string) for _, b := range s.Branches { - sha, _ := git.HeadSHA(b.Branch) + sha, err := git.HeadSHA(b.Branch) + if err != nil { + cfg.Errorf("failed to resolve HEAD SHA for %s: %s", b.Branch, err) + return nil + } originalRefs[b.Branch] = sha } @@ -213,7 +217,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { UseOnto: true, OntoOldBase: originalRefs[br.Branch], } - saveRebaseState(gitDir, state) + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } printConflictDetails(cfg, newBase) cfg.Printf("") @@ -261,7 +267,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { OriginalBranch: currentBranch, OriginalRefs: originalRefs, } - saveRebaseState(gitDir, state) + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } printConflictDetails(cfg, base) cfg.Printf("") @@ -408,7 +416,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { state.CurrentBranchIndex = idx state.ConflictBranch = branchName state.OntoOldBase = state.OriginalRefs[branchName] - saveRebaseState(gitDir, state) + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, newBase) printConflictDetails(cfg, newBase) @@ -448,7 +458,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { state.RemainingBranches = state.RemainingBranches[remainIdx+1:] state.CurrentBranchIndex = idx state.ConflictBranch = branchName - saveRebaseState(gitDir, state) + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, base) printConflictDetails(cfg, base) @@ -509,21 +521,41 @@ func abortRebase(cfg *config.Config, gitDir string) error { _ = git.RebaseAbort() } + var restoreErrors []string for branch, sha := range state.OriginalRefs { - _ = git.CheckoutBranch(branch) - _ = git.ResetHard(sha) + if err := git.CheckoutBranch(branch); err != nil { + restoreErrors = append(restoreErrors, fmt.Sprintf("checkout %s: %s", branch, err)) + continue + } + if err := git.ResetHard(sha); err != nil { + restoreErrors = append(restoreErrors, fmt.Sprintf("reset %s: %s", branch, err)) + } } _ = git.CheckoutBranch(state.OriginalBranch) clearRebaseState(gitDir) - cfg.Successf("Rebase aborted and branches restored") + if len(restoreErrors) > 0 { + cfg.Warningf("Rebase aborted but some branches could not be fully restored:") + for _, e := range restoreErrors { + cfg.Printf(" %s", e) + } + return nil + } + + cfg.Successf("Rebase aborted and branches restored") return nil } -func saveRebaseState(gitDir string, state *rebaseState) { - data, _ := json.MarshalIndent(state, "", " ") - _ = os.WriteFile(filepath.Join(gitDir, rebaseStateFile), data, 0644) +func saveRebaseState(gitDir string, state *rebaseState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("error serializing rebase state: %w", err) + } + if err := os.WriteFile(filepath.Join(gitDir, rebaseStateFile), data, 0644); err != nil { + return fmt.Errorf("error writing rebase state: %w", err) + } + return nil } func loadRebaseState(gitDir string) (*rebaseState, error) { diff --git a/cmd/view.go b/cmd/view.go index 4dd84fd..4b01b9d 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -172,6 +172,10 @@ func runPager(cfg *config.Config, content string) error { } args := strings.Fields(pagerCmd) + if len(args) == 0 { + _, err := fmt.Fprint(cfg.Out, content) + return err + } if args[0] == "less" { hasR := false for _, a := range args[1:] { @@ -250,16 +254,16 @@ func viewWeb(cfg *config.Config, s *stack.Stack) error { } url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number) if err := b.Browse(url); err != nil { - cfg.Warningf("failed to open %s: %v\n", url, err) + cfg.Warningf("failed to open %s: %v", url, err) } else { opened++ } } if opened == 0 { - cfg.Printf("No PRs found to open in browser.\n") + cfg.Printf("No PRs found to open in browser.") } else { - cfg.Successf("Opened %d PRs in browser\n", opened) + cfg.Successf("Opened %d PRs in browser", opened) } return nil diff --git a/internal/config/config.go b/internal/config/config.go index e2652e9..13d0366 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "io" "os" "github.com/cli/go-gh/v2/pkg/repository" @@ -15,9 +14,9 @@ import ( // Config holds shared state for all commands. type Config struct { Terminal term.Term - Out io.Writer - Err io.Writer - In io.Reader + Out *os.File + Err *os.File + In *os.File ColorSuccess func(string) string ColorError func(string) string @@ -34,8 +33,8 @@ func New() *Config { terminal := term.FromEnv() cfg := &Config{ Terminal: terminal, - Out: terminal.Out(), - Err: terminal.ErrOut(), + Out: os.Stdout, + Err: os.Stderr, In: os.Stdin, } diff --git a/internal/git/git.go b/internal/git/git.go index c508235..072b752 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,6 +4,7 @@ import ( "context" "os" "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -197,8 +198,8 @@ func IsRebaseInProgress() bool { return false } for _, dir := range []string{"rebase-merge", "rebase-apply"} { - cmd := exec.Command("test", "-d", gitDir+"/"+dir) - if cmd.Run() == nil { + rebasePath := filepath.Join(gitDir, dir) + if info, err := os.Stat(rebasePath); err == nil && info.IsDir() { return true } } diff --git a/internal/github/github.go b/internal/github/github.go index f73b82e..3038c5a 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -9,15 +9,15 @@ import ( // PullRequest represents a GitHub pull request. type PullRequest struct { - ID string - Number int - Title string - State string - URL string - HeadRefName string - BaseRefName string - IsDraft bool - Merged bool + ID string `graphql:"id"` + Number int `graphql:"number"` + Title string `graphql:"title"` + State string `graphql:"state"` + URL string `graphql:"url"` + HeadRefName string `graphql:"headRefName"` + BaseRefName string `graphql:"baseRefName"` + IsDraft bool `graphql:"isDraft"` + Merged bool `graphql:"merged"` } // Client wraps GitHub API operations. From d77d2fe2d02e1b95871d6735ebf2c3c433b3d4d5 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 09:31:24 -0400 Subject: [PATCH 20/78] update readme --- README.md | 358 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 357 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f79d62a..dac4d8d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,358 @@ # gh-stack -A GitHub CLI extension to manage stacked branches and PRs + +A GitHub CLI extension for managing stacked branches and pull requests. + +Stacked PRs break large changes into a chain of small, reviewable pull requests that build on each other. `gh stack` automates the tedious parts — creating branches, keeping them rebased, setting correct PR base branches, and navigating between layers. + +## Installation + +```sh +gh extension install github/gh-stack +``` + +Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+. + +## Quick start + +```sh +# Start a new stack from the default branch +gh stack init + +# Create the first branch and start working +gh stack add auth-layer +# ... make commits ... + +# Add another branch on top +gh stack add api-endpoints +# ... make commits ... + +# Push all branches and create/update PRs +gh stack push + +# View the stack +gh stack view +``` + +## How it works + +A **stack** is an ordered list of branches where each branch builds on the one below it. The bottom of the stack is based on a **trunk** branch (typically `main`). + +``` +main (trunk) + └── auth-layer → PR #1 (base: main) + └── api-endpoints → PR #2 (base: auth-layer) +``` + +When you push, `gh stack` creates one PR per branch. Each PR's base is set to the branch below it in the stack (**branch-chaining**), so reviewers see only the diff for that layer. + +### Local tracking + +Stack metadata is stored in `.git/gh-stack` (a JSON file, not committed to the repo). This tracks which branches belong to which stack and their ordering. Rebase state during interrupted rebases is stored separately in `.git/gh-stack-rebase-state`. + +## Commands + +### `gh stack init` + +Initialize a new stack in the current repository. + +``` +gh stack init [branches...] [flags] +``` + +Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. + +Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. + +| Flag | Description | +|------|-------------| +| `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | +| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | + +**Examples:** + +```sh +# Interactive — prompts for branch names +gh stack init + +# Non-interactive — specify branches upfront +gh stack init feature-auth feature-api feature-ui + +# Use a different trunk branch +gh stack init --base develop feature-auth + +# Adopt existing branches into a stack +gh stack init --adopt feature-auth feature-api +``` + +### `gh stack add` + +Add a new branch on top of the current stack. + +``` +gh stack add [branch] +``` + +Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. + +**Examples:** + +```sh +gh stack add api-routes +gh stack add # prompts for name +``` + +### `gh stack checkout` + +Discover and check out an entire stack from a pull request or branch. + +``` +gh stack checkout [flags] +``` + +Accepts a PR number, PR URL, or branch name. Traces the chain of PRs to discover the full stack, fetches all branches, and saves the stack to local tracking. + +> **Note:** This command is not yet implemented. Running it prints a notice. + +| Flag | Description | +|------|-------------| +| `--no-switch` | Fetch and track the stack without switching to the target branch | + +**Examples:** + +```sh +gh stack checkout 42 +gh stack checkout feature-auth +gh stack checkout https://github.com/owner/repo/pull/42 +``` + +### `gh stack rebase` + +Pull from remote and do a cascading rebase across the stack. + +``` +gh stack rebase [branch] [flags] +``` + +Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. If a branch's PR has been squash-merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. + +If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. + +| Flag | Description | +|------|-------------| +| `--downstack` | Only rebase branches from trunk to the current branch | +| `--upstack` | Only rebase branches from the current branch to the top | +| `--continue` | Continue the rebase after resolving conflicts | +| `--abort` | Abort the rebase and restore all branches to their pre-rebase state | + +| Argument | Description | +|----------|-------------| +| `[branch]` | Target branch (defaults to the current branch) | + +**Examples:** + +```sh +# Rebase the entire stack +gh stack rebase + +# Only rebase branches below the current one +gh stack rebase --downstack + +# Only rebase branches above the current one +gh stack rebase --upstack + +# After resolving a conflict +gh stack rebase --continue + +# Give up and restore everything +gh stack rebase --abort +``` + +### `gh stack sync` + +Fetch, rebase, push, and sync PR state in a single command. + +``` +gh stack sync +``` + +Performs a safe, non-interactive synchronization of the entire stack: + +1. **Fetch** — fetches the latest changes from `origin` +2. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged) +3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state and you are advised to run `gh stack rebase` to resolve conflicts interactively +4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred) +5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR + +**Examples:** + +```sh +gh stack sync +``` + +### `gh stack push` + +Push all branches in the current stack and create or update pull requests. + +``` +gh stack push [flags] +``` + +Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. + +| Flag | Description | +|------|-------------| +| `-f, --force` | Force-push branches | +| `--draft` | Create new PRs as drafts | +| `--dry-run` | Show what would be pushed without actually pushing | + +**Examples:** + +```sh +gh stack push +gh stack push --force +gh stack push --draft +gh stack push --dry-run +``` + +### `gh stack view` + +View the current stack. + +``` +gh stack view [flags] +``` + +Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`). + +| Flag | Description | +|------|-------------| +| `-s, --short` | Compact output (branch names only) | +| `-w, --web` | Open all associated PRs in the browser | + +**Examples:** + +```sh +gh stack view +gh stack view --short +gh stack view --web +``` + +### `gh stack unstack` + +Remove a stack from local tracking and optionally delete it on GitHub. + +``` +gh stack unstack [branch] [flags] +``` + +If no branch is specified, uses the current branch to find the stack. By default, the stack is removed from both local tracking and GitHub. Use `--local` to only remove the local tracking entry. + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (keep it on GitHub) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | A branch in the stack to delete (defaults to the current branch) | + +**Examples:** + +```sh +# Remove the stack from local tracking and GitHub +gh stack unstack + +# Only remove local tracking +gh stack unstack --local + +# Specify a branch to identify the stack +gh stack unstack feature-auth +``` + +### `gh stack merge` + +Merge a stack of PRs. + +``` +gh stack merge +``` + +Merges the specified PR and all PRs below it in the stack. + +> **Note:** This command is not yet implemented. Running it prints a notice. + +### Navigation + +Move between branches in the current stack without having to remember branch names. + +```sh +gh stack up [n] # Move up n branches (default 1) +gh stack down [n] # Move down n branches (default 1) +gh stack top # Jump to the top of the stack +gh stack bottom # Jump to the bottom of the stack +``` + +Navigation commands clamp to the bounds of the stack — moving up from the top or down from the bottom is a no-op with a message. If you're on the trunk branch, `up` moves to the first stack branch. + +**Examples:** + +```sh +gh stack up # move up one layer +gh stack up 3 # move up three layers +gh stack down +gh stack top +gh stack bottom +``` + +### `gh stack feedback` + +Share feedback about gh-stack. + +``` +gh stack feedback [title] +``` + +Opens a GitHub Discussion in the [gh-stack repository](https://github.com/github/gh-stack) to submit feedback. Optionally provide a title for the discussion post. + +**Examples:** + +```sh +gh stack feedback +gh stack feedback "Support for reordering branches" +``` + +### Placeholder commands + +The following commands are planned but not yet implemented. Running them prints a notice and suggests using `gh stack feedback` to share your interest. + +`remove` · `modify` · `reorder` · `move` · `fold` · `squash` · `rename` · `split` + +## Typical workflow + +```sh +# 1. Start a stack +gh stack init +gh stack add auth-middleware + +# 2. Work on the first layer +# ... write code, make commits ... + +# 3. Add the next layer +gh stack add api-routes +# ... write code, make commits ... + +# 4. Push everything and create PRs +gh stack push + +# 5. Reviewer requests changes on the first PR +gh stack bottom +# ... make changes, commit ... + +# 6. Rebase the rest of the stack on top of your fix +gh stack rebase + +# 7. Force-push the updated stack +gh stack push --force + +# 8. When the first PR is merged, sync the stack +gh stack sync +``` From c2a11067c6cf93454bb37ec7d3fbb2b8eb4662b4 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 15:15:16 -0400 Subject: [PATCH 21/78] show pr status in view --- cmd/view.go | 56 ++++++++++++++++++++++++++++++++++++++++++--- internal/git/git.go | 4 +++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/cmd/view.go b/cmd/view.go index 4b01b9d..59f70ba 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -90,18 +90,66 @@ func runView(cfg *config.Config, opts *viewOptions) error { } func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { + var repoOwner, repoName string + if repo, err := cfg.Repo(); err == nil { + repoOwner = repo.Owner + repoName = repo.Name + } + for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] + merged := b.PullRequest != nil && b.PullRequest.Merged + indicator := branchStatusIndicator(cfg, s, b) + prSuffix := shortPRSuffix(cfg, b, repoOwner, repoName) if b.Branch == currentBranch { - cfg.Outf("● %s %s\n", cfg.ColorBold(b.Branch), cfg.ColorCyan("(current)")) + cfg.Outf("» %s%s%s %s\n", cfg.ColorBold(b.Branch), indicator, prSuffix, cfg.ColorCyan("(current)")) + } else if merged { + cfg.Outf("│ %s%s%s\n", cfg.ColorGray(b.Branch), indicator, prSuffix) } else { - cfg.Outf("○ %s\n", b.Branch) + cfg.Outf("├ %s%s%s\n", b.Branch, indicator, prSuffix) } } cfg.Outf("└ %s\n", s.Trunk.Branch) return nil } +// branchStatusIndicator returns a colored status icon for a branch: +// - ✓ (purple) if the PR has been merged +// - ⚠ (yellow) if the branch needs rebasing (non-linear history) +// - ○ (green) if there is an open PR +func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef) string { + if b.PullRequest != nil && b.PullRequest.Merged { + return " " + cfg.ColorMagenta("✓") + } + + baseBranch := s.BaseBranch(b.Branch) + if needsRebase, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !needsRebase { + return " " + cfg.ColorWarning("⚠") + } + + if b.PullRequest != nil && b.PullRequest.Number != 0 { + return " " + cfg.ColorSuccess("○") + } + + return "" +} + +func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) string { + if b.PullRequest == nil || b.PullRequest.Number == 0 { + return "" + } + prNum := fmt.Sprintf("#%d", b.PullRequest.Number) + if owner != "" && repo != "" { + url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, b.PullRequest.Number) + prNum = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, prNum) + } + colorFn := cfg.ColorSuccess // green for open + if b.PullRequest.Merged { + colorFn = cfg.ColorMagenta // purple for merged + } + return fmt.Sprintf(" %s", colorFn(prNum)) +} + func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { client, clientErr := cfg.GitHubClient() @@ -123,6 +171,8 @@ func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { bullet = "●" } + indicator := branchStatusIndicator(cfg, s, b) + prInfo := "" if clientErr == nil && repoErr == nil { pr, err := client.FindPRForBranch(b.Branch) @@ -136,7 +186,7 @@ func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { branchName = cfg.ColorCyan(b.Branch + " (current)") } - fmt.Fprintf(&buf, "%s %s%s\n", bullet, branchName, prInfo) + fmt.Fprintf(&buf, "%s %s %s%s\n", bullet, branchName, indicator, prInfo) commits, err := git.Log(b.Branch, 1) if err == nil && len(commits) > 0 { diff --git a/internal/git/git.go b/internal/git/git.go index 072b752..1c058cb 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -277,7 +278,8 @@ func IsAncestor(ancestor, descendant string) (bool, error) { return true, nil } // Exit code 1 means "not an ancestor", which is not an error condition. - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { return false, nil } return false, err From 910b69ce71b212d1d0af01f6aef69da7543d1252 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 15:24:58 -0400 Subject: [PATCH 22/78] force push with lease by default --- README.md | 8 +++----- cmd/push.go | 4 +--- cmd/rebase.go | 4 ++-- cmd/sync.go | 4 ++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dac4d8d..a90ad5f 100644 --- a/README.md +++ b/README.md @@ -197,11 +197,10 @@ Push all branches in the current stack and create or update pull requests. gh stack push [flags] ``` -Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. +Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. Uses `--force-with-lease` by default to safely update rebased branches. | Flag | Description | |------|-------------| -| `-f, --force` | Force-push branches | | `--draft` | Create new PRs as drafts | | `--dry-run` | Show what would be pushed without actually pushing | @@ -209,7 +208,6 @@ Pushes every branch to the remote, then for each branch either creates a new PR ```sh gh stack push -gh stack push --force gh stack push --draft gh stack push --dry-run ``` @@ -350,8 +348,8 @@ gh stack bottom # 6. Rebase the rest of the stack on top of your fix gh stack rebase -# 7. Force-push the updated stack -gh stack push --force +# 7. Push the updated stack +gh stack push # 8. When the first PR is merged, sync the stack gh stack sync diff --git a/cmd/push.go b/cmd/push.go index 00c34fe..4439ef6 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -10,7 +10,6 @@ import ( ) type pushOptions struct { - force bool draft bool dryRun bool } @@ -26,7 +25,6 @@ func PushCmd(cfg *config.Config) *cobra.Command { }, } - cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Force-push branches") cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Show what would be pushed without pushing") @@ -83,7 +81,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } cfg.Printf("Pushing %s...", b.Branch) - if err := git.Push("origin", []string{b.Branch}, opts.force, false); err != nil { + if err := git.Push("origin", []string{b.Branch}, true, false); err != nil { cfg.Errorf("failed to push %s: %s", b.Branch, err) return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index 6303e44..05d893f 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -320,7 +320,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch) cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", - cfg.ColorCyan("gh stack push -f")) + cfg.ColorCyan("gh stack push")) return nil } @@ -505,7 +505,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch) cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", - cfg.ColorCyan("gh stack push -f")) + cfg.ColorCyan("gh stack push")) return nil } diff --git a/cmd/sync.go b/cmd/sync.go index 717d789..f627fac 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -249,10 +249,10 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if !force { cfg.Warningf("Push failed — branches may need force push after rebase") cfg.Printf(" Run %s to push with --force-with-lease.", - cfg.ColorCyan("gh stack push -f")) + cfg.ColorCyan("gh stack push")) } else { cfg.Warningf("Push failed: %v", err) - cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push -f")) + cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push")) } } else { cfg.Successf("Pushed %d branches", len(branches)) From 9dc79804687c5b4b4744c80733d07585dbc2291a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 21:33:57 -0400 Subject: [PATCH 23/78] output msgs nits --- cmd/add.go | 2 +- cmd/checkout.go | 2 +- cmd/feedback.go | 2 +- cmd/merge.go | 2 +- cmd/rebase.go | 2 +- cmd/root.go | 2 ++ cmd/utils.go | 7 +++---- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index b47d62c..3993336 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -106,6 +106,6 @@ func runAdd(cfg *config.Config, args []string) error { return nil } - cfg.Successf("Created and checked out branch %q\n", branchName) + cfg.Successf("Created and checked out branch %q", branchName) return nil } diff --git a/cmd/checkout.go b/cmd/checkout.go index 3584845..baa2a3b 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -38,6 +38,6 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command { // 4. Save the stack to local tracking (.git/gh-stack, similar to gh stack init --adopt) // 5. Switch to the target branch (unless --no-switch is set) func runCheckout(cfg *config.Config, opts *checkoutOptions) error { - cfg.Warningf("gh stack checkout is not yet implemented\n") + cfg.Warningf("gh stack checkout is not yet implemented") return nil } diff --git a/cmd/feedback.go b/cmd/feedback.go index 24e398d..94195c1 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -37,6 +37,6 @@ func runFeedback(cfg *config.Config, args []string) error { return err } - cfg.Successf("Opening feedback form in your browser...\n") + cfg.Successf("Opening feedback form in your browser...") return nil } diff --git a/cmd/merge.go b/cmd/merge.go index e1928ba..2e318f3 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -26,6 +26,6 @@ func MergeCmd(cfg *config.Config) *cobra.Command { // We need a mergeability check for the entire stack // and an endpoint for merging an entire stack func runMerge(cfg *config.Config, opts struct{}) error { - cfg.Warningf("gh stack merge is not yet implemented\n") + cfg.Warningf("gh stack merge is not yet implemented") return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index 05d893f..c6464af 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -118,7 +118,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { if err := git.Fetch("origin"); err != nil { cfg.Warningf("Failed to fetch origin: %v", err) } else { - cfg.Successf("Fetching origin") + cfg.Successf("Fetched origin") } chainParts := []string{s.Trunk.Branch} diff --git a/cmd/root.go b/cmd/root.go index fe1e4a6..2bc975b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "github.com/github/gh-stack/internal/config" @@ -56,6 +57,7 @@ func RootCmd() *cobra.Command { func Execute() { cmd := RootCmd() if err := cmd.Execute(); err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), err) os.Exit(1) } } diff --git a/cmd/utils.go b/cmd/utils.go index b87a19a..86510ba 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" @@ -30,14 +29,14 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac return nil, fmt.Errorf("branch %q belongs to multiple stacks; use an interactive terminal to select one", branch) } - cfg.Warningf("Branch %q is the trunk of multiple stacks\n", branch) + cfg.Warningf("Branch %q is the trunk of multiple stacks", branch) options := make([]string, len(stacks)) for i, s := range stacks { options[i] = s.DisplayName() } - p := prompter.New(os.Stdin, os.Stdout, os.Stderr) + p := prompter.New(cfg.In, cfg.Out, cfg.Err) selected, err := p.Select("Which stack would you like to use?", "", options) if err != nil { return nil, fmt.Errorf("stack selection: %w", err) @@ -52,7 +51,7 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac if err := git.CheckoutBranch(topBranch); err != nil { return nil, fmt.Errorf("failed to checkout branch %s: %w", topBranch, err) } - cfg.Successf("Switched to %s\n", topBranch) + cfg.Successf("Switched to %s", topBranch) } return s, nil From 423f492ecb34024dc111f2b8985eb94d17a2f07e Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 21:36:12 -0400 Subject: [PATCH 24/78] check if empty stack --- cmd/utils.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/utils.go b/cmd/utils.go index 86510ba..a33b3eb 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -44,6 +44,10 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac s := stacks[selected] + if len(s.Branches) == 0 { + return nil, fmt.Errorf("selected stack %q has no branches", s.DisplayName()) + } + // Switch to the top branch of the selected stack so future commands // resolve unambiguously. topBranch := s.Branches[len(s.Branches)-1].Branch From e4b0b6e6e974512dcbd694fd921764f1a4760b91 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 23:07:58 -0400 Subject: [PATCH 25/78] use cached pr info --- cmd/view.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cmd/view.go b/cmd/view.go index 59f70ba..7803874 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -174,7 +174,11 @@ func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { indicator := branchStatusIndicator(cfg, s, b) prInfo := "" - if clientErr == nil && repoErr == nil { + if b.PullRequest != nil { + if url := b.PullRequest.URL; url != "" { + prInfo = " " + url + } + } else if clientErr == nil && repoErr == nil { pr, err := client.FindPRForBranch(b.Branch) if err == nil && pr != nil { prInfo = fmt.Sprintf(" https://github.com/%s/%s/pull/%d", repoOwner, repoName, pr.Number) @@ -298,11 +302,16 @@ func viewWeb(cfg *config.Config, s *stack.Stack) error { opened := 0 for _, br := range s.Branches { - pr, err := client.FindPRForBranch(br.Branch) - if err != nil || pr == nil { - continue + var url string + if br.PullRequest != nil && br.PullRequest.URL != "" { + url = br.PullRequest.URL + } else { + pr, err := client.FindPRForBranch(br.Branch) + if err != nil || pr == nil { + continue + } + url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number) } - url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number) if err := b.Browse(url); err != nil { cfg.Warningf("failed to open %s: %v", url, err) } else { From b9bfd8644ffa53be34edbd4284c83b532e7bcb1b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 23:23:01 -0400 Subject: [PATCH 26/78] init as empty slice --- internal/stack/stack.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 3d5fd02..4f47a00 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -149,6 +149,7 @@ func Load(gitDir string) (*StackFile, error) { if errors.Is(err, os.ErrNotExist) { return &StackFile{ SchemaVersion: schemaVersion, + Stacks: []Stack{}, }, nil } return nil, fmt.Errorf("reading stack file: %w", err) @@ -165,6 +166,9 @@ func Load(gitDir string) (*StackFile, error) { // Save writes the stack file to the given git directory. func Save(gitDir string, sf *StackFile) error { sf.SchemaVersion = schemaVersion + if sf.Stacks == nil { + sf.Stacks = []Stack{} + } data, err := json.MarshalIndent(sf, "", " ") if err != nil { return fmt.Errorf("marshaling stack file: %w", err) From 7767bb499d19f94c367588e2f32c6f842a31de4b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 00:37:51 -0400 Subject: [PATCH 27/78] handle rebase restore failures --- cmd/sync.go | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index f627fac..0c2cd23 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" @@ -175,14 +177,11 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if git.IsRebaseInProgress() { _ = git.RebaseAbort() } - for branch, sha := range originalRefs { - _ = git.CheckoutBranch(branch) - _ = git.ResetHard(sha) - } + restoreErrors := restoreBranches(originalRefs) _ = git.CheckoutBranch(currentBranch) cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, newBase) - cfg.Printf(" All branches restored to their original state.") + reportRestoreStatus(cfg, restoreErrors) cfg.Printf(" Run %s to resolve conflicts interactively.", cfg.ColorCyan("gh stack rebase")) conflicted = true @@ -210,14 +209,11 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if git.IsRebaseInProgress() { _ = git.RebaseAbort() } - for branch, sha := range originalRefs { - _ = git.CheckoutBranch(branch) - _ = git.ResetHard(sha) - } + restoreErrors := restoreBranches(originalRefs) _ = git.CheckoutBranch(currentBranch) cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base) - cfg.Printf(" All branches restored to their original state.") + reportRestoreStatus(cfg, restoreErrors) cfg.Printf(" Run %s to resolve conflicts interactively.", cfg.ColorCyan("gh stack rebase")) conflicted = true @@ -318,6 +314,33 @@ func updateBranchRef(branch, sha string) error { return git.UpdateBranchRef(branch, sha) } +// restoreBranches resets each branch to its original SHA, collecting any errors. +func restoreBranches(originalRefs map[string]string) []string { + var errors []string + for branch, sha := range originalRefs { + if err := git.CheckoutBranch(branch); err != nil { + errors = append(errors, fmt.Sprintf("checkout %s: %s", branch, err)) + continue + } + if err := git.ResetHard(sha); err != nil { + errors = append(errors, fmt.Sprintf("reset %s: %s", branch, err)) + } + } + return errors +} + +// reportRestoreStatus prints whether branch restoration succeeded or partially failed. +func reportRestoreStatus(cfg *config.Config, restoreErrors []string) { + if len(restoreErrors) > 0 { + cfg.Warningf("Some branches could not be fully restored:") + for _, e := range restoreErrors { + cfg.Printf(" %s", e) + } + } else { + cfg.Printf(" All branches restored to their original state.") + } +} + // short returns the first 7 characters of a SHA. func short(sha string) string { if len(sha) > 7 { From 071748c3f4778066a24c373011bde3617a19e1a0 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 01:21:38 -0400 Subject: [PATCH 28/78] handle edges cases from review --- cmd/rebase.go | 3 +++ internal/git/git.go | 5 ++++- internal/stack/stack.go | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index c6464af..4b5c9f2 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -374,6 +374,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { for _, branchName := range state.RemainingBranches { idx := s.IndexOf(branchName) + if idx < 0 { + return fmt.Errorf("branch %q from saved rebase state is no longer in the stack — the stack may have been modified since the rebase started; consider aborting with --abort", branchName) + } // Skip branches whose PRs have already been merged. br := s.Branches[idx] diff --git a/internal/git/git.go b/internal/git/git.go index 1c058cb..add8836 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -173,7 +173,10 @@ func tryAutoResolveRebase(originalErr error) error { if !IsRebaseInProgress() { return nil } - conflicts, _ := ConflictedFiles() + conflicts, err := ConflictedFiles() + if err != nil { + return originalErr + } if len(conflicts) > 0 { return originalErr } diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 4f47a00..49f3393 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -160,6 +160,10 @@ func Load(gitDir string) (*StackFile, error) { return nil, fmt.Errorf("parsing stack file: %w", err) } + if sf.SchemaVersion > schemaVersion { + return nil, fmt.Errorf("stack file has schema version %d, but this version of gh-stack only supports up to version %d — please upgrade gh-stack", sf.SchemaVersion, schemaVersion) + } + return &sf, nil } From b7c15c1da6d361702f35aea802e09491c82b67c7 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 11 Mar 2026 19:43:36 -0400 Subject: [PATCH 29/78] interactive terminal ui for view --- cmd/view.go | 47 +++ go.mod | 8 +- go.sum | 17 + internal/git/git.go | 88 +++++ internal/github/github.go | 62 +++ internal/tui/stackview/data.go | 84 +++++ internal/tui/stackview/model.go | 628 +++++++++++++++++++++++++++++++ internal/tui/stackview/styles.go | 43 +++ 8 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 internal/tui/stackview/data.go create mode 100644 internal/tui/stackview/model.go create mode 100644 internal/tui/stackview/styles.go diff --git a/cmd/view.go b/cmd/view.go index 7803874..3a05d61 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -8,10 +8,12 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/cli/go-gh/v2/pkg/browser" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" "github.com/spf13/cobra" ) @@ -151,6 +153,51 @@ func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) st } func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { + if !cfg.IsInteractive() { + return viewFullStatic(cfg, s, currentBranch) + } + + return viewFullTUI(cfg, s, currentBranch) +} + +func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string) error { + // Load enriched data for all branches + nodes := stackview.LoadBranchNodes(cfg, s, currentBranch) + + // Reverse nodes so index 0 = top of stack (matches visual order) + reversed := make([]stackview.BranchNode, len(nodes)) + for i, n := range nodes { + reversed[len(nodes)-1-i] = n + } + + model := stackview.New(reversed, s.Trunk) + + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithMouseAllMotion(), + ) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("running TUI: %w", err) + } + + // Checkout branch if user requested it + if m, ok := finalModel.(stackview.Model); ok { + if branch := m.CheckoutBranch(); branch != "" { + if err := git.CheckoutBranch(branch); err != nil { + cfg.Errorf("failed to checkout %s: %v", branch, err) + } else { + cfg.Successf("Switched to %s", branch) + } + } + } + + return nil +} + +func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) error { client, clientErr := cfg.GitHubClient() var repoOwner, repoName string diff --git a/go.mod b/go.mod index e53f761..e158530 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/github/gh-stack go 1.25.7 require ( + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cli/cli/v2 v2.86.0 github.com/cli/go-gh/v2 v2.13.0 github.com/cli/shurcooL-graphql v0.0.4 @@ -14,13 +17,13 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/ansi v0.10.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/safeexec v1.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/henvic/httpretty v0.1.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -29,7 +32,10 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index 261a69b..1050c8e 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,12 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= @@ -14,6 +20,8 @@ github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvA github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= @@ -35,6 +43,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= @@ -59,12 +69,18 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -109,6 +125,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/git/git.go b/internal/git/git.go index add8836..5cd0c42 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -325,6 +325,94 @@ func Log(ref string, maxCount int) ([]CommitInfo, error) { return commits, nil } +// LogRange returns commits in the range base..head (commits reachable from head +// but not from base). This is useful for seeing all commits unique to a branch. +func LogRange(base, head string) ([]CommitInfo, error) { + format := "%H\t%s\t%at" + rangeSpec := base + ".." + head + output, err := run("log", rangeSpec, "--format="+format) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(parts[2], 10, 64) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: parts[1], + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} + +// DiffStatRange returns the total additions and deletions between two refs. +func DiffStatRange(base, head string) (additions, deletions int, err error) { + output, err := run("diff", "--numstat", base+".."+head) + if err != nil { + return 0, 0, err + } + if output == "" { + return 0, 0, nil + } + for _, line := range strings.Split(output, "\n") { + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + // Binary files show "-" instead of numbers + if parts[0] == "-" { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + additions += a + deletions += d + } + return additions, deletions, nil +} + +// FileDiffStat holds per-file diff statistics. +type FileDiffStat struct { + Path string + Additions int + Deletions int +} + +// DiffStatFiles returns per-file additions and deletions between two refs. +func DiffStatFiles(base, head string) ([]FileDiffStat, error) { + output, err := run("diff", "--numstat", base+".."+head) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + var files []FileDiffStat + for _, line := range strings.Split(output, "\n") { + parts := strings.Fields(line) + if len(parts) < 3 { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + files = append(files, FileDiffStat{ + Path: parts[2], + Additions: a, + Deletions: d, + }) + } + return files, nil +} + // DeleteBranch deletes a local branch. func DeleteBranch(name string, force bool) error { flag := "-d" diff --git a/internal/github/github.go b/internal/github/github.go index 3038c5a..c54efe2 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -210,6 +210,68 @@ func (c *Client) UpdatePRBase(prID, newBase string) error { return c.gql.Mutate("UpdatePullRequest", &mutation, variables) } +// PRDetails holds enriched pull request data for display in the TUI. +type PRDetails struct { + Number int + Title string + State string // OPEN, CLOSED, MERGED + URL string + IsDraft bool + Merged bool + CommentsCount int +} + +// FindPRDetailsForBranch fetches enriched PR data for display purposes. +// Returns nil without error if no PR exists for the branch. +func (c *Client) FindPRDetailsForBranch(branch string) (*PRDetails, error) { + var query struct { + Repository struct { + PullRequests struct { + Nodes []struct { + ID string `graphql:"id"` + Number int `graphql:"number"` + Title string `graphql:"title"` + State string `graphql:"state"` + URL string `graphql:"url"` + HeadRefName string `graphql:"headRefName"` + BaseRefName string `graphql:"baseRefName"` + IsDraft bool `graphql:"isDraft"` + Merged bool `graphql:"merged"` + Comments struct { + TotalCount int `graphql:"totalCount"` + } `graphql:"comments"` + } + } `graphql:"pullRequests(headRefName: $head, last: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "head": graphql.String(branch), + } + + if err := c.gql.Query("FindPRDetailsForBranch", &query, variables); err != nil { + return nil, fmt.Errorf("querying PR details: %w", err) + } + + nodes := query.Repository.PullRequests.Nodes + if len(nodes) == 0 { + return nil, nil + } + + n := nodes[0] + return &PRDetails{ + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + IsDraft: n.IsDraft, + Merged: n.Merged, + CommentsCount: n.Comments.TotalCount, + }, nil +} + // DeleteStack deletes a stack on GitHub. // TODO: Implement once the stack API is available. func (c *Client) DeleteStack() error { diff --git a/internal/tui/stackview/data.go b/internal/tui/stackview/data.go new file mode 100644 index 0000000..e5d5aa0 --- /dev/null +++ b/internal/tui/stackview/data.go @@ -0,0 +1,84 @@ +package stackview + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +// BranchNode holds all display data for a single branch in the stack. +type BranchNode struct { + Ref stack.BranchRef + IsCurrent bool + IsLinear bool // whether history is linear with base branch + BaseBranch string + Commits []git.CommitInfo // commits unique to this branch (base..head) + FilesChanged []git.FileDiffStat // per-file diff stats + PR *ghapi.PRDetails + Additions int + Deletions int + + // UI state + CommitsExpanded bool + FilesExpanded bool +} + +// LoadBranchNodes populates branch display data from a stack. +func LoadBranchNodes(cfg *config.Config, s *stack.Stack, currentBranch string) []BranchNode { + client, clientErr := cfg.GitHubClient() + + nodes := make([]BranchNode, len(s.Branches)) + + for i, b := range s.Branches { + baseBranch := s.BaseBranch(b.Branch) + + node := BranchNode{ + Ref: b, + IsCurrent: b.Branch == currentBranch, + BaseBranch: baseBranch, + IsLinear: true, + } + + // Check linearity (is base an ancestor of this branch?) + if isAncestor, err := git.IsAncestor(baseBranch, b.Branch); err == nil { + node.IsLinear = isAncestor + } + + // For merged branches, use the merge-base (fork point) as the diff + // anchor since the base branch has moved past the merge point and + // a two-dot diff would show nothing after a squash merge. + isMerged := b.PullRequest != nil && b.PullRequest.Merged + diffBase := baseBranch + if isMerged { + if mb, err := git.MergeBase(baseBranch, b.Branch); err == nil { + diffBase = mb + } + } + + // Fetch commit range + if commits, err := git.LogRange(diffBase, b.Branch); err == nil { + node.Commits = commits + } + + // Compute per-file diff stats from local git + if files, err := git.DiffStatFiles(diffBase, b.Branch); err == nil { + node.FilesChanged = files + for _, f := range files { + node.Additions += f.Additions + node.Deletions += f.Deletions + } + } + + // Fetch enriched PR details + if clientErr == nil { + if pr, err := client.FindPRDetailsForBranch(b.Branch); err == nil && pr != nil { + node.PR = pr + } + } + + nodes[i] = node + } + + return nodes +} diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go new file mode 100644 index 0000000..29b3478 --- /dev/null +++ b/internal/tui/stackview/model.go @@ -0,0 +1,628 @@ +package stackview + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/stack" +) + +// keyMap defines the key bindings for the stack view. +type keyMap struct { + Up key.Binding + Down key.Binding + ToggleCommits key.Binding + ToggleFiles key.Binding + OpenPR key.Binding + Checkout key.Binding + Quit key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.ToggleCommits, k.ToggleFiles, k.OpenPR, k.Checkout, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + ToggleCommits: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "commits"), + ), + ToggleFiles: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "files"), + ), + OpenPR: key.NewBinding( + key.WithKeys("o"), + key.WithHelp("o", "open PR"), + ), + Checkout: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "checkout"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +// Model is the Bubbletea model for the interactive stack view. +type Model struct { + nodes []BranchNode + trunk stack.BranchRef + cursor int // index into nodes (displayed top-down, so 0 = top of stack) + help help.Model + width int + height int + + // scrollOffset tracks vertical scroll position for tall stacks. + scrollOffset int + + // checkoutBranch is set when the user wants to checkout a branch after quitting. + checkoutBranch string +} + +// New creates a new stack view model. +func New(nodes []BranchNode, trunk stack.BranchRef) Model { + h := help.New() + h.ShowAll = true + + // Cursor starts at the current branch, or top of stack + cursor := 0 + for i, n := range nodes { + if n.IsCurrent { + cursor = i + break + } + } + + return Model{ + nodes: nodes, + trunk: trunk, + cursor: cursor, + help: h, + } +} + +// CheckoutBranch returns the branch to checkout after the TUI exits, if any. +func (m Model) CheckoutBranch() string { + return m.checkoutBranch +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.help.Width = msg.Width + return m, nil + + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Quit): + return m, tea.Quit + + case key.Matches(msg, keys.Up): + if m.cursor > 0 { + m.cursor-- + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.Down): + if m.cursor < len(m.nodes)-1 { + m.cursor++ + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.ToggleCommits): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].CommitsExpanded = !m.nodes[m.cursor].CommitsExpanded + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.ToggleFiles): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].FilesExpanded = !m.nodes[m.cursor].FilesExpanded + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.OpenPR): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + node := m.nodes[m.cursor] + if node.PR != nil && node.PR.URL != "" { + openBrowserInBackground(node.PR.URL) + } + } + return m, nil + + case key.Matches(msg, keys.Checkout): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + node := m.nodes[m.cursor] + if !node.IsCurrent { + m.checkoutBranch = node.Ref.Branch + return m, tea.Quit + } + } + return m, nil + } + + case tea.MouseMsg: + switch msg.Action { + case tea.MouseActionPress: + if msg.Button == tea.MouseButtonLeft { + return m.handleMouseClick(msg.Y) + } + if msg.Button == tea.MouseButtonWheelUp { + if m.scrollOffset > 0 { + m.scrollOffset-- + } + return m, nil + } + if msg.Button == tea.MouseButtonWheelDown { + m.scrollOffset++ + return m, nil + } + } + } + + return m, nil +} + +// openBrowserInBackground launches the system browser for the given URL. +func openBrowserInBackground(url string) { + cmd := browserCmd(url) + _ = cmd.Start() +} + +// handleMouseClick processes a mouse click at the given screen row. +func (m Model) handleMouseClick(screenY int) (tea.Model, tea.Cmd) { + // Map screen Y to content line, accounting for scroll offset + contentLine := screenY + m.scrollOffset + + // Walk through rendered lines to find which node was clicked + line := 0 + for i := 0; i < len(m.nodes); i++ { + nodeStart := line + nodeLines := m.nodeLineCount(i) + + if contentLine >= nodeStart && contentLine < nodeStart+nodeLines { + m.cursor = i + // If clicking on the commits toggle line, toggle expansion + commitToggleLine := nodeStart + m.commitToggleLineOffset(i) + if contentLine == commitToggleLine && len(m.nodes[i].Commits) > 0 { + m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded + } + return m, nil + } + line += nodeLines + } + + return m, nil +} + +// nodeLineCount returns how many rendered lines a node occupies. +func (m Model) nodeLineCount(idx int) int { + node := m.nodes[idx] + lines := 1 // header line (PR line or branch line) + + if node.PR != nil { + lines++ // branch + diff stats line (below PR header) + } + + if len(node.FilesChanged) > 0 { + lines++ // files toggle line + if node.FilesExpanded { + lines += len(node.FilesChanged) + } + } + + if len(node.Commits) > 0 { + lines++ // commits toggle line + if node.CommitsExpanded { + lines += len(node.Commits) + } + } + + lines++ // connector/spacer line + return lines +} + +// commitToggleLineOffset returns the offset from node start to the commits toggle line. +func (m Model) commitToggleLineOffset(idx int) int { + node := m.nodes[idx] + offset := 1 // after header + if node.PR != nil { + offset++ // branch + diff line + } + if len(node.FilesChanged) > 0 { + offset++ // files toggle line + if node.FilesExpanded { + offset += len(node.FilesChanged) + } + } + return offset +} + +// ensureVisible adjusts scroll offset so the cursor is visible. +func (m *Model) ensureVisible() { + if m.height == 0 { + return + } + + // Calculate the line range for the cursor node + startLine := 0 + for i := 0; i < m.cursor; i++ { + startLine += m.nodeLineCount(i) + } + endLine := startLine + m.nodeLineCount(m.cursor) + + // Available content height (reserve 2 for help bar) + viewHeight := m.height - 2 + if viewHeight < 1 { + viewHeight = 1 + } + + if startLine < m.scrollOffset { + m.scrollOffset = startLine + } + if endLine > m.scrollOffset+viewHeight { + m.scrollOffset = endLine - viewHeight + } +} + +func (m Model) View() string { + if m.width == 0 { + return "" + } + + var b strings.Builder + + // Render nodes in order (index 0 = top of stack, displayed first) + for i := 0; i < len(m.nodes); i++ { + m.renderNode(&b, i) + } + + // Trunk + b.WriteString(connectorStyle.Render("└ ")) + b.WriteString(trunkStyle.Render(m.trunk.Branch)) + b.WriteString("\n") + + content := b.String() + contentLines := strings.Split(content, "\n") + + // Apply scrolling + viewHeight := m.height - 2 // reserve for help bar + if viewHeight < 1 { + viewHeight = 1 + } + + start := m.scrollOffset + if start > len(contentLines) { + start = len(contentLines) + } + end := start + viewHeight + if end > len(contentLines) { + end = len(contentLines) + } + + visibleContent := strings.Join(contentLines[start:end], "\n") + + // Add help bar at the bottom + helpView := m.help.View(keys) + + return visibleContent + "\n" + helpView +} + +// renderNode renders a single branch node. +func (m Model) renderNode(b *strings.Builder, idx int) { + node := m.nodes[idx] + isFocused := idx == m.cursor + + // Determine connector character and style + connector := "│" + connStyle := connectorStyle + isMerged := node.PR != nil && node.PR.Merged + if !node.IsLinear && !isMerged { + connector = "┊" + connStyle = connectorDashedStyle + } + // Override style when this node is focused + if isFocused { + if node.IsCurrent { + connStyle = connectorCurrentStyle + } else { + connStyle = connectorFocusedStyle + } + } + + // Render header: either PR line + branch line, or just branch line + if node.PR != nil { + m.renderPRHeader(b, node, isFocused, connStyle) + m.renderBranchLine(b, node, connector, connStyle) + } else { + m.renderBranchHeader(b, node, isFocused, connStyle) + } + + // Files changed toggle + expanded file list + if len(node.FilesChanged) > 0 { + m.renderFiles(b, node, connector, connStyle) + } + + // Commits toggle + expanded commits + if len(node.Commits) > 0 { + m.renderCommits(b, node, connector, connStyle) + } + + // Connector/spacer + b.WriteString(connStyle.Render(connector)) + b.WriteString("\n") +} + +// renderPRHeader renders the top line when a PR exists: bullet + status icon + PR number + state. +func (m Model) renderPRHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) { + bullet := "├" + if isFocused { + bullet = "▶" + } + + b.WriteString(connStyle.Render(bullet + " ")) + + statusIcon := m.statusIcon(node) + + if statusIcon != "" { + b.WriteString(statusIcon + " ") + } + + // PR number + state label + pr := node.PR + prLabel := fmt.Sprintf("#%d", pr.Number) + stateLabel := "" + style := prOpenStyle + switch { + case pr.Merged: + stateLabel = " MERGED" + style = prMergedStyle + case pr.State == "CLOSED": + stateLabel = " CLOSED" + style = prClosedStyle + case pr.IsDraft: + stateLabel = " DRAFT" + style = prDraftStyle + default: + stateLabel = " OPEN" + } + b.WriteString(style.Render(prLabel + stateLabel)) + + b.WriteString("\n") +} + +// renderBranchLine renders the branch name + diff stats below the PR header. +func (m Model) renderBranchLine(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + branchName := node.Ref.Branch + if node.IsCurrent { + b.WriteString(currentBranchStyle.Render(branchName + " (current)")) + } else if node.PR != nil && node.PR.Merged { + b.WriteString(normalBranchStyle.Render(branchName)) + } else { + b.WriteString(normalBranchStyle.Render(branchName)) + } + + m.renderDiffStats(b, node) + b.WriteString("\n") +} + +// renderBranchHeader renders the header line when there is no PR: bullet + branch name + diff stats. +func (m Model) renderBranchHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) { + bullet := "├" + if isFocused { + bullet = "▶" + } + + b.WriteString(connStyle.Render(bullet + " ")) + + // Status indicator + statusIcon := m.statusIcon(node) + if statusIcon != "" { + b.WriteString(statusIcon + " ") + } + + // Branch name + branchName := node.Ref.Branch + if node.IsCurrent { + b.WriteString(currentBranchStyle.Render(branchName + " (current)")) + } else { + b.WriteString(normalBranchStyle.Render(branchName)) + } + + m.renderDiffStats(b, node) + b.WriteString("\n") +} + +// renderDiffStats appends +N -N diff stats to the current line if available. +func (m Model) renderDiffStats(b *strings.Builder, node BranchNode) { + if node.Additions > 0 || node.Deletions > 0 { + b.WriteString(" ") + b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", node.Additions))) + b.WriteString(" ") + b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", node.Deletions))) + } +} + +// statusIcon returns the appropriate status icon for a branch. +func (m Model) statusIcon(node BranchNode) string { + if node.PR != nil && node.PR.Merged { + return mergedIcon + } + if !node.IsLinear { + return warningIcon + } + if node.PR != nil && node.PR.Number != 0 { + return openIcon + } + return "" +} + +// renderFiles renders the files changed toggle and optionally the expanded file list. +func (m Model) renderFiles(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + icon := collapsedIcon + if node.FilesExpanded { + icon = expandedIcon + } + fileLabel := "files changed" + if len(node.FilesChanged) == 1 { + fileLabel = "file changed" + } + b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.FilesChanged), fileLabel))) + b.WriteString("\n") + + if !node.FilesExpanded { + return + } + + for _, f := range node.FilesChanged { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + path := f.Path + maxLen := m.width - 30 + if maxLen < 20 { + maxLen = 20 + } + if len(path) > maxLen { + path = "…" + path[len(path)-maxLen+1:] + } + b.WriteString(normalBranchStyle.Render(path)) + b.WriteString(" ") + b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", f.Additions))) + b.WriteString(" ") + b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", f.Deletions))) + b.WriteString("\n") + } +} + +// renderCommits renders the commits toggle and optionally the expanded commit list. +func (m Model) renderCommits(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + icon := collapsedIcon + if node.CommitsExpanded { + icon = expandedIcon + } + commitLabel := "commits" + if len(node.Commits) == 1 { + commitLabel = "commit" + } + b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.Commits), commitLabel))) + b.WriteString("\n") + + if !node.CommitsExpanded { + return + } + + for _, c := range node.Commits { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + sha := c.SHA + if len(sha) > 7 { + sha = sha[:7] + } + b.WriteString(commitSHAStyle.Render(sha)) + b.WriteString(" ") + + subject := c.Subject + maxLen := m.width - 35 + if maxLen < 20 { + maxLen = 20 + } + if len(subject) > maxLen { + subject = subject[:maxLen-1] + "…" + } + b.WriteString(commitSubjectStyle.Render(subject)) + b.WriteString(" ") + b.WriteString(commitTimeStyle.Render(timeAgo(c.Time))) + b.WriteString("\n") + } +} + +// timeAgo returns a human-readable time-ago string. +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + secs := int(d.Seconds()) + if secs == 1 { + return "1 second ago" + } + return fmt.Sprintf("%d seconds ago", secs) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case d < 30*24*time.Hour: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + default: + months := int(d.Hours() / 24 / 30) + if months <= 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + } +} + +// browserCmd returns an exec.Cmd to open a URL in the default browser. +func browserCmd(url string) *exec.Cmd { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url) + case "windows": + return exec.Command("cmd", "/c", "start", url) + default: + return exec.Command("xdg-open", url) + } +} diff --git a/internal/tui/stackview/styles.go b/internal/tui/stackview/styles.go new file mode 100644 index 0000000..2698e83 --- /dev/null +++ b/internal/tui/stackview/styles.go @@ -0,0 +1,43 @@ +package stackview + +import "github.com/charmbracelet/lipgloss" + +var ( + // Branch name styles + currentBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // cyan bold + normalBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white + mergedBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + trunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) // gray italic + + // Focus indicator — reserved for future use + + // Status indicators + mergedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("✓") // magenta + warningIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠") // yellow + openIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("○") // green + + // PR status + prOpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + prMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta + prClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + prDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + + // Diff stats + additionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + deletionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + + // Commit lines + commitSHAStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow + commitSubjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white + commitTimeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + + // Connector lines + connectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + connectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow (non-linear) + connectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white (focused) + connectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan (current branch focused) + + // Expand/collapse toggle + expandedIcon = "▾" + collapsedIcon = "▸" +) From d5305eb4bac86fa2bdaa73981b86f36d20a1a1eb Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 09:24:32 -0400 Subject: [PATCH 30/78] improved handling of merged branches --- cmd/navigate.go | 91 +++++++++++++++++++++++--- cmd/push.go | 19 ++++-- cmd/rebase.go | 30 ++++++--- cmd/sync.go | 79 ++++++++++++++--------- cmd/view.go | 27 +++++++- internal/stack/stack.go | 63 ++++++++++++++++++ internal/tui/stackview/data.go | 2 +- internal/tui/stackview/model.go | 107 ++++++++++++++++++++++++++----- internal/tui/stackview/styles.go | 4 ++ 9 files changed, 347 insertions(+), 75 deletions(-) diff --git a/cmd/navigate.go b/cmd/navigate.go index c50bc75..9db9480 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -70,7 +70,13 @@ func runNavigate(cfg *config.Config, delta int) error { // Might be on the trunk if currentBranch == s.Trunk.Branch { if delta > 0 && len(s.Branches) > 0 { - target := s.Branches[0].Branch + targetIdx := s.FirstActiveBranchIndex() + if targetIdx < 0 { + // All merged — fall back to top branch with warning + targetIdx = len(s.Branches) - 1 + cfg.Warningf("Warning: all branches in this stack have been merged") + } + target := s.Branches[targetIdx].Branch if err := git.CheckoutBranch(target); err != nil { return err } @@ -84,12 +90,65 @@ func runNavigate(cfg *config.Config, delta int) error { return nil } - newIdx := idx + delta - if newIdx < 0 { - newIdx = 0 + onMerged := s.Branches[idx].IsMerged() + if onMerged { + cfg.Warningf("Warning: you are on merged branch %q", currentBranch) } - if newIdx >= len(s.Branches) { - newIdx = len(s.Branches) - 1 + + var newIdx int + var skipped int + + if onMerged { + // Navigate relative to current position among ALL branches + newIdx = idx + delta + if newIdx < 0 { + newIdx = 0 + } + if newIdx >= len(s.Branches) { + newIdx = len(s.Branches) - 1 + } + } else { + // Build list of active (non-merged) branch indices + var activeIndices []int + for i, b := range s.Branches { + if !b.IsMerged() { + activeIndices = append(activeIndices, i) + } + } + + // Find current position in active list + activePos := -1 + for i, ai := range activeIndices { + if ai == idx { + activePos = i + break + } + } + + newActivePos := activePos + delta + if newActivePos < 0 { + newActivePos = 0 + } + if newActivePos >= len(activeIndices) { + newActivePos = len(activeIndices) - 1 + } + + newIdx = activeIndices[newActivePos] + + // Count how many merged branches were skipped + if newIdx > idx { + for i := idx + 1; i < newIdx; i++ { + if s.Branches[i].IsMerged() { + skipped++ + } + } + } else if newIdx < idx { + for i := newIdx + 1; i < idx; i++ { + if s.Branches[i].IsMerged() { + skipped++ + } + } + } } if newIdx == idx { @@ -106,6 +165,10 @@ func runNavigate(cfg *config.Config, delta int) error { return err } + if skipped > 0 { + cfg.Printf("Skipped %d merged %s", skipped, plural(skipped, "branch", "branch(es)")) + } + moved := newIdx - idx direction := "up" if moved < 0 { @@ -129,13 +192,19 @@ func runNavigateToEnd(cfg *config.Config, top bool) error { return nil } - var target string + var targetIdx int if top { - target = s.Branches[len(s.Branches)-1].Branch + targetIdx = len(s.Branches) - 1 } else { - target = s.Branches[0].Branch + targetIdx = s.FirstActiveBranchIndex() + if targetIdx < 0 { + // All merged — fall back to first branch with warning + targetIdx = 0 + cfg.Warningf("Warning: all branches in this stack have been merged") + } } + target := s.Branches[targetIdx].Branch if target == currentBranch { if top { cfg.Printf("Already at the top of the stack") @@ -149,6 +218,10 @@ func runNavigateToEnd(cfg *config.Config, top bool) error { return err } + if s.Branches[targetIdx].IsMerged() { + cfg.Warningf("Warning: you are on merged branch %q", target) + } + cfg.Successf("Switched to %s", target) return nil } diff --git a/cmd/push.go b/cmd/push.go index 4439ef6..f4bd75d 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -74,7 +74,11 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } // Push all branches - for _, b := range s.Branches { + merged := s.MergedBranches() + if len(merged) > 0 { + cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches")) + } + for _, b := range s.ActiveBranches() { if opts.dryRun { cfg.Printf("Would push %s", b.Branch) continue @@ -93,7 +97,10 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Create or update PRs for i, b := range s.Branches { - baseBranch := s.BaseBranch(b.Branch) + if s.Branches[i].IsMerged() { + continue + } + baseBranch := s.ActiveBaseBranch(b.Branch) pr, err := client.FindPRForBranch(b.Branch) if err != nil { @@ -150,10 +157,10 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Update base commit hashes and sync PR state for i := range s.Branches { - parent := s.Trunk.Branch - if i > 0 { - parent = s.Branches[i-1].Branch + if s.Branches[i].IsMerged() { + continue } + parent := s.ActiveBaseBranch(s.Branches[i].Branch) if base, err := git.HeadSHA(parent); err == nil { s.Branches[i].Base = base } @@ -168,6 +175,6 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } - cfg.Successf("Pushed and synced %d branches", len(s.Branches)) + cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index 4b5c9f2..2eacd90 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -132,6 +133,10 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { currentIdx = 0 } + if opts.upstack && currentIdx >= 0 && s.Branches[currentIdx].IsMerged() { + cfg.Warningf("Current branch %q has already been merged", currentBranch) + } + startIdx := 0 endIdx := len(s.Branches) @@ -180,7 +185,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { // Skip branches whose PRs have already been merged (e.g. via squash). // Record state so subsequent branches can use --onto rebase. - if br.PullRequest != nil && br.PullRequest.Merged { + if br.IsMerged() { ontoOldBase = originalRefs[br.Branch] needsOnto = true cfg.Successf("Skipping %s (PR #%d merged)", br.Branch, br.PullRequest.Number) @@ -192,7 +197,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { newBase := s.Trunk.Branch for j := absIdx - 1; j >= 0; j-- { b := s.Branches[j] - if b.PullRequest == nil || !b.PullRequest.Merged { + if !b.IsMerged() { newBase = b.Branch break } @@ -289,13 +294,13 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { for i := range s.Branches { // Skip merged branches when updating base SHAs. - if s.Branches[i].PullRequest != nil && s.Branches[i].PullRequest.Merged { + if s.Branches[i].IsMerged() { continue } // Find the first non-merged ancestor, or trunk. parent := s.Trunk.Branch for j := i - 1; j >= 0; j-- { - if s.Branches[j].PullRequest == nil || !s.Branches[j].PullRequest.Merged { + if !s.Branches[j].IsMerged() { parent = s.Branches[j].Branch break } @@ -311,6 +316,15 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { _ = stack.Save(gitDir, sf) + merged := s.MergedBranches() + if len(merged) > 0 { + names := make([]string, len(merged)) + for i, m := range merged { + names[i] = m.Branch + } + cfg.Printf("Skipped %d merged %s: %s", len(merged), plural(len(merged), "branch", "branches"), strings.Join(names, ", ")) + } + rangeDesc := "All branches in stack" if opts.downstack { rangeDesc = fmt.Sprintf("All downstack branches up to %s", currentBranch) @@ -380,7 +394,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { // Skip branches whose PRs have already been merged. br := s.Branches[idx] - if br.PullRequest != nil && br.PullRequest.Merged { + if br.IsMerged() { state.OntoOldBase = state.OriginalRefs[branchName] state.UseOnto = true cfg.Successf("Skipping %s (PR #%d merged)", branchName, br.PullRequest.Number) @@ -399,7 +413,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { newBase := s.Trunk.Branch for j := idx - 1; j >= 0; j-- { b := s.Branches[j] - if b.PullRequest == nil || !b.PullRequest.Merged { + if !b.IsMerged() { newBase = b.Branch break } @@ -484,13 +498,13 @@ func continueRebase(cfg *config.Config, gitDir string) error { for i := range s.Branches { // Skip merged branches when updating base SHAs. - if s.Branches[i].PullRequest != nil && s.Branches[i].PullRequest.Merged { + if s.Branches[i].IsMerged() { continue } // Find the first non-merged ancestor, or trunk. parent := s.Trunk.Branch for j := i - 1; j >= 0; j-- { - if s.Branches[j].PullRequest == nil || !s.Branches[j].PullRequest.Merged { + if !s.Branches[j].IsMerged() { parent = s.Branches[j].Branch break } diff --git a/cmd/sync.go b/cmd/sync.go index 0c2cd23..8e0c5b1 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -154,7 +155,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { } // Skip branches whose PRs have already been merged. - if br.PullRequest != nil && br.PullRequest.Merged { + if br.IsMerged() { ontoOldBase = originalRefs[br.Branch] needsOnto = true cfg.Successf("Skipping %s (PR #%d merged)", br.Branch, br.PullRequest.Number) @@ -166,7 +167,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { newBase := trunk for j := i - 1; j >= 0; j-- { b := s.Branches[j] - if b.PullRequest == nil || !b.PullRequest.Merged { + if !b.IsMerged() { newBase = b.Branch break } @@ -232,26 +233,36 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // --- Step 4: Push --- cfg.Printf("") - branches := make([]string, len(s.Branches)) - for i, b := range s.Branches { - branches[i] = b.Branch + var branches []string + for _, b := range s.Branches { + if !b.IsMerged() { + branches = append(branches, b.Branch) + } } - // After rebase, force-with-lease is required (history rewritten). - // Without rebase, try a normal push first. - force := rebased - cfg.Printf("Pushing branches ...") - if err := git.Push("origin", branches, force, false); err != nil { - if !force { - cfg.Warningf("Push failed — branches may need force push after rebase") - cfg.Printf(" Run %s to push with --force-with-lease.", - cfg.ColorCyan("gh stack push")) + if mergedCount := len(s.MergedBranches()); mergedCount > 0 { + cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches")) + } + + if len(branches) == 0 { + cfg.Printf("No active branches to push (all merged)") + } else { + // After rebase, force-with-lease is required (history rewritten). + // Without rebase, try a normal push first. + force := rebased + cfg.Printf("Pushing branches ...") + if err := git.Push("origin", branches, force, false); err != nil { + if !force { + cfg.Warningf("Push failed — branches may need force push after rebase") + cfg.Printf(" Run %s to push with --force-with-lease.", + cfg.ColorCyan("gh stack push")) + } else { + cfg.Warningf("Push failed: %v", err) + cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push")) + } } else { - cfg.Warningf("Push failed: %v", err) - cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push")) + cfg.Successf("Pushed %d branches", len(branches)) } - } else { - cfg.Successf("Pushed %d branches", len(branches)) } // --- Step 5: Sync PR state --- @@ -261,31 +272,35 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // Report PR status for each branch for _, b := range s.Branches { + if b.IsMerged() { + continue + } if b.PullRequest != nil { - state := "Open" - if b.PullRequest.Merged { - state = "Merged" - } - cfg.Successf("PR #%d (%s) — %s", b.PullRequest.Number, b.Branch, state) + cfg.Successf("PR #%d (%s) — Open", b.PullRequest.Number, b.Branch) } else { cfg.Warningf("%s has no PR", b.Branch) } } + merged := s.MergedBranches() + if len(merged) > 0 { + names := make([]string, len(merged)) + for i, m := range merged { + if m.PullRequest != nil { + names[i] = fmt.Sprintf("#%d", m.PullRequest.Number) + } else { + names[i] = m.Branch + } + } + cfg.Printf("Merged: %s", strings.Join(names, ", ")) + } // --- Step 6: Update base SHAs and save --- for i := range s.Branches { // Skip merged branches when updating base SHAs. - if s.Branches[i].PullRequest != nil && s.Branches[i].PullRequest.Merged { + if s.Branches[i].IsMerged() { continue } - // Find the first non-merged ancestor, or trunk. - parent := trunk - for j := i - 1; j >= 0; j-- { - if s.Branches[j].PullRequest == nil || !s.Branches[j].PullRequest.Merged { - parent = s.Branches[j].Branch - break - } - } + parent := s.ActiveBaseBranch(s.Branches[i].Branch) if base, err := git.HeadSHA(parent); err == nil { s.Branches[i].Base = base } diff --git a/cmd/view.go b/cmd/view.go index 3a05d61..2e1aaf2 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -100,7 +100,13 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] - merged := b.PullRequest != nil && b.PullRequest.Merged + merged := b.IsMerged() + + // Insert separator when transitioning from active to merged section + if merged && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) { + cfg.Outf("├─── %s ────\n", cfg.ColorMagenta("merged")) + } + indicator := branchStatusIndicator(cfg, s, b) prSuffix := shortPRSuffix(cfg, b, repoOwner, repoName) if b.Branch == currentBranch { @@ -120,11 +126,11 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { // - ⚠ (yellow) if the branch needs rebasing (non-linear history) // - ○ (green) if there is an open PR func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef) string { - if b.PullRequest != nil && b.PullRequest.Merged { + if b.IsMerged() { return " " + cfg.ColorMagenta("✓") } - baseBranch := s.BaseBranch(b.Branch) + baseBranch := s.ActiveBaseBranch(b.Branch) if needsRebase, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !needsRebase { return " " + cfg.ColorWarning("⚠") } @@ -145,6 +151,8 @@ func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) st url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, b.PullRequest.Number) prNum = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, prNum) } + // Underline to hint that the PR number is a clickable link + prNum = fmt.Sprintf("\033[4m%s\033[24m", prNum) colorFn := cfg.ColorSuccess // green for open if b.PullRequest.Merged { colorFn = cfg.ColorMagenta // purple for merged @@ -211,6 +219,12 @@ func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) er for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] + + // Insert separator when transitioning from active to merged section + if b.IsMerged() && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) { + fmt.Fprintf(&buf, "╌╌╌ %s ╌╌╌\n", cfg.ColorGray("merged")) + } + isCurrent := b.Branch == currentBranch bullet := "○" @@ -349,6 +363,9 @@ func viewWeb(cfg *config.Config, s *stack.Stack) error { opened := 0 for _, br := range s.Branches { + if br.IsMerged() { + continue + } var url string if br.PullRequest != nil && br.PullRequest.URL != "" { url = br.PullRequest.URL @@ -372,5 +389,9 @@ func viewWeb(cfg *config.Config, s *stack.Stack) error { cfg.Successf("Opened %d PRs in browser", opened) } + if mergedCount := len(s.MergedBranches()); mergedCount > 0 { + cfg.Printf("Skipped %d merged PRs", mergedCount) + } + return nil } diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 49f3393..808db2e 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -86,6 +86,69 @@ func (s *Stack) BaseBranch(branch string) string { return s.Branches[idx-1].Branch } +// IsMerged returns whether a branch's PR has been merged. +func (b *BranchRef) IsMerged() bool { + return b.PullRequest != nil && b.PullRequest.Merged +} + +// ActiveBranches returns only non-merged branches, preserving order. +func (s *Stack) ActiveBranches() []BranchRef { + var active []BranchRef + for _, b := range s.Branches { + if !b.IsMerged() { + active = append(active, b) + } + } + return active +} + +// MergedBranches returns only merged branches, preserving order. +func (s *Stack) MergedBranches() []BranchRef { + var merged []BranchRef + for _, b := range s.Branches { + if b.IsMerged() { + merged = append(merged, b) + } + } + return merged +} + +// FirstActiveBranchIndex returns the index of the first non-merged branch, or -1. +func (s *Stack) FirstActiveBranchIndex() int { + for i, b := range s.Branches { + if !b.IsMerged() { + return i + } + } + return -1 +} + +// ActiveBaseBranch returns the effective parent for a branch, skipping merged +// ancestors. For the first active branch (or any branch whose downstack is all +// merged), this returns the trunk. +func (s *Stack) ActiveBaseBranch(branch string) string { + idx := s.IndexOf(branch) + if idx <= 0 { + return s.Trunk.Branch + } + for j := idx - 1; j >= 0; j-- { + if !s.Branches[j].IsMerged() { + return s.Branches[j].Branch + } + } + return s.Trunk.Branch +} + +// IsFullyMerged returns true if all branches in the stack have been merged. +func (s *Stack) IsFullyMerged() bool { + for _, b := range s.Branches { + if !b.IsMerged() { + return false + } + } + return len(s.Branches) > 0 +} + // StackFile represents the JSON file stored in .git/gh-stack. type StackFile struct { SchemaVersion int `json:"schemaVersion"` diff --git a/internal/tui/stackview/data.go b/internal/tui/stackview/data.go index e5d5aa0..ae47446 100644 --- a/internal/tui/stackview/data.go +++ b/internal/tui/stackview/data.go @@ -31,7 +31,7 @@ func LoadBranchNodes(cfg *config.Config, s *stack.Stack, currentBranch string) [ nodes := make([]BranchNode, len(s.Branches)) for i, b := range s.Branches { - baseBranch := s.BaseBranch(b.Branch) + baseBranch := s.ActiveBaseBranch(b.Branch) node := BranchNode{ Ref: b, diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 29b3478..cd6d2b4 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -16,13 +16,13 @@ import ( // keyMap defines the key bindings for the stack view. type keyMap struct { - Up key.Binding - Down key.Binding + Up key.Binding + Down key.Binding ToggleCommits key.Binding - ToggleFiles key.Binding - OpenPR key.Binding - Checkout key.Binding - Quit key.Binding + ToggleFiles key.Binding + OpenPR key.Binding + Checkout key.Binding + Quit key.Binding } func (k keyMap) ShortHelp() []key.Binding { @@ -176,7 +176,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Action { case tea.MouseActionPress: if msg.Button == tea.MouseButtonLeft { - return m.handleMouseClick(msg.Y) + return m.handleMouseClick(msg.X, msg.Y) } if msg.Button == tea.MouseButtonWheelUp { if m.scrollOffset > 0 { @@ -200,24 +200,52 @@ func openBrowserInBackground(url string) { _ = cmd.Start() } -// handleMouseClick processes a mouse click at the given screen row. -func (m Model) handleMouseClick(screenY int) (tea.Model, tea.Cmd) { +// handleMouseClick processes a mouse click at the given screen position. +func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { // Map screen Y to content line, accounting for scroll offset contentLine := screenY + m.scrollOffset - // Walk through rendered lines to find which node was clicked + // Walk through rendered lines to find which node was clicked. + // Account for the merged separator line that may appear between nodes. line := 0 + prevWasMerged := false for i := 0; i < len(m.nodes); i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + line++ // separator line + } + prevWasMerged = isMerged + nodeStart := line nodeLines := m.nodeLineCount(i) if contentLine >= nodeStart && contentLine < nodeStart+nodeLines { m.cursor = i - // If clicking on the commits toggle line, toggle expansion - commitToggleLine := nodeStart + m.commitToggleLineOffset(i) - if contentLine == commitToggleLine && len(m.nodes[i].Commits) > 0 { - m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded + + // Click on PR header line — only open browser if clicking the PR number + if contentLine == nodeStart && m.nodes[i].PR != nil && m.nodes[i].PR.URL != "" { + prStartX, prEndX := m.prLabelColumns(i) + if screenX >= prStartX && screenX < prEndX { + openBrowserInBackground(m.nodes[i].PR.URL) + } + } + + // Click on files toggle line → toggle expansion + if len(m.nodes[i].FilesChanged) > 0 { + filesToggleLine := nodeStart + m.filesToggleLineOffset(i) + if contentLine == filesToggleLine { + m.nodes[i].FilesExpanded = !m.nodes[i].FilesExpanded + } } + + // Click on commits toggle line → toggle expansion + if len(m.nodes[i].Commits) > 0 { + commitToggleLine := nodeStart + m.commitToggleLineOffset(i) + if contentLine == commitToggleLine { + m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded + } + } + return m, nil } line += nodeLines @@ -269,17 +297,56 @@ func (m Model) commitToggleLineOffset(idx int) int { return offset } +// filesToggleLineOffset returns the offset from node start to the files toggle line. +func (m Model) filesToggleLineOffset(idx int) int { + node := m.nodes[idx] + offset := 1 // after header + if node.PR != nil { + offset++ // branch + diff line + } + return offset +} + +// prLabelColumns returns the start and end X columns of the PR number label +// (e.g. "#123") on the PR header line, for click hit-testing. +func (m Model) prLabelColumns(idx int) (int, int) { + node := m.nodes[idx] + // Layout: "├ " (2) + optional status icon + " " (2) + "#N..." + col := 2 // bullet + space + if node.PR != nil && (node.PR.Merged || !node.IsLinear || node.PR.Number != 0) { + icon := m.statusIcon(node) + if icon != "" { + col += 2 // icon (1 visible char) + space + } + } + prLabel := fmt.Sprintf("#%d", node.PR.Number) + return col, col + len(prLabel) +} + // ensureVisible adjusts scroll offset so the cursor is visible. func (m *Model) ensureVisible() { if m.height == 0 { return } - // Calculate the line range for the cursor node + // Calculate the line range for the cursor node, accounting for separator lines startLine := 0 + prevWasMerged := false for i := 0; i < m.cursor; i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + startLine++ // separator line + } + prevWasMerged = isMerged startLine += m.nodeLineCount(i) } + // Check if the cursor node itself is preceded by a separator + if m.cursor < len(m.nodes) { + isMerged := m.nodes[m.cursor].Ref.IsMerged() + if isMerged && !prevWasMerged && m.cursor > 0 { + startLine++ + } + } endLine := startLine + m.nodeLineCount(m.cursor) // Available content height (reserve 2 for help bar) @@ -304,8 +371,14 @@ func (m Model) View() string { var b strings.Builder // Render nodes in order (index 0 = top of stack, displayed first) + prevWasMerged := false for i := 0; i < len(m.nodes); i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + b.WriteString(connectorStyle.Render("────") + dimStyle.Render(" merged ") + connectorStyle.Render("─────") + "\n") + } m.renderNode(&b, i) + prevWasMerged = isMerged } // Trunk @@ -356,6 +429,8 @@ func (m Model) renderNode(b *strings.Builder, idx int) { if isFocused { if node.IsCurrent { connStyle = connectorCurrentStyle + } else if isMerged { + connStyle = connectorMergedStyle } else { connStyle = connectorFocusedStyle } @@ -417,7 +492,7 @@ func (m Model) renderPRHeader(b *strings.Builder, node BranchNode, isFocused boo default: stateLabel = " OPEN" } - b.WriteString(style.Render(prLabel + stateLabel)) + b.WriteString(style.Underline(true).Render(prLabel) + style.Render(stateLabel)) b.WriteString("\n") } diff --git a/internal/tui/stackview/styles.go b/internal/tui/stackview/styles.go index 2698e83..10c2c32 100644 --- a/internal/tui/stackview/styles.go +++ b/internal/tui/stackview/styles.go @@ -36,6 +36,10 @@ var ( connectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow (non-linear) connectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white (focused) connectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan (current branch focused) + connectorMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta (merged branch focused) + + // Dim text (separators, secondary labels) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // Expand/collapse toggle expandedIcon = "▾" From 9c8d2fb9a339077115b00e175a2fdbc20327de2b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 09:25:57 -0400 Subject: [PATCH 31/78] block add to stack if all branches merged --- cmd/add.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/add.go b/cmd/add.go index 3993336..bac671a 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -57,6 +57,12 @@ func runAdd(cfg *config.Config, args []string) error { return nil } + if s.IsFullyMerged() { + cfg.Warningf("All branches in this stack have been merged") + cfg.Printf("Consider creating a new stack with %s", cfg.ColorCyan("gh stack init")) + return nil + } + idx := s.IndexOf(currentBranch) if idx >= 0 && idx < len(s.Branches)-1 { cfg.Errorf("can only add branches on top of the stack; checkout the top branch %q first", s.Branches[len(s.Branches)-1].Branch) From 07ea259bcb40a53ae29593ef1c7e34453fe7dda2 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 10:25:42 -0400 Subject: [PATCH 32/78] checkout for local stacks --- README.md | 19 ++--- cmd/checkout.go | 152 ++++++++++++++++++++++++++++++++++++---- internal/stack/stack.go | 14 ++++ 3 files changed, 163 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a90ad5f..b30b32b 100644 --- a/README.md +++ b/README.md @@ -103,26 +103,27 @@ gh stack add # prompts for name ### `gh stack checkout` -Discover and check out an entire stack from a pull request or branch. +Check out a locally tracked stack from a pull request number or branch name. ``` -gh stack checkout [flags] +gh stack checkout [] ``` -Accepts a PR number, PR URL, or branch name. Traces the chain of PRs to discover the full stack, fetches all branches, and saves the stack to local tracking. +Resolves the target against stacks stored in local tracking (`.git/gh-stack`). Accepts a PR number (e.g. `42`) or a branch name that belongs to a locally tracked stack. When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. -> **Note:** This command is not yet implemented. Running it prints a notice. - -| Flag | Description | -|------|-------------| -| `--no-switch` | Fetch and track the stack without switching to the target branch | +> **Note:** Server-side stack discovery is not yet implemented. This command currently only works with stacks that have been created locally (via `gh stack init`). Checking out a stack that is not tracked locally will require passing in an explicit branch name or PR number once the server API is available. **Examples:** ```sh +# Check out a stack by PR number gh stack checkout 42 + +# Check out a stack by branch name gh stack checkout feature-auth -gh stack checkout https://github.com/owner/repo/pull/42 + +# Interactive — select from locally tracked stacks +gh stack checkout ``` ### `gh stack rebase` diff --git a/cmd/checkout.go b/cmd/checkout.go index baa2a3b..8ee61d9 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -1,43 +1,169 @@ package cmd import ( + "fmt" + "strconv" + + "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) type checkoutOptions struct { - target string - noSwitch bool + target string } func CheckoutCmd(cfg *config.Config) *cobra.Command { opts := &checkoutOptions{} cmd := &cobra.Command{ - Use: "checkout ", + Use: "checkout []", Short: "Checkout a stack from a PR number or branch name", - Long: "Discover and check out an entire stack from a pull request number, URL, or branch name.", - Args: cobra.ExactArgs(1), + Long: `Check out a stack from a pull request number or branch name. + +Currently resolves stacks from local tracking only (.git/gh-stack). +Accepts a PR number (e.g. 42) or a branch name that belongs to +a locally tracked stack. When run without arguments, shows a menu of +all locally available stacks to choose from. + +Server-side stack discovery will be added in a future release.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.target = args[0] + if len(args) > 0 { + opts.target = args[0] + } return runCheckout(cfg, opts) }, } - cmd.Flags().BoolVar(&opts.noSwitch, "no-switch", false, "Fetch and track the stack without switching branches") - return cmd } -// runCheckout is a placeholder for the stack checkout workflow. +// runCheckout resolves a stack from local tracking and checks out the target branch. // -// The intended behavior is: -// 1. Resolve the target (PR number, URL, or branch name) to a PR +// Future behavior (once the server API is available): +// 1. Resolve the target (PR number, URL, or branch name) to a PR via the API // 2. If the PR is part of a stack, discover the full set of PRs in the stack // 3. Fetch and create local tracking branches for every branch in the stack // 4. Save the stack to local tracking (.git/gh-stack, similar to gh stack init --adopt) -// 5. Switch to the target branch (unless --no-switch is set) +// 5. Switch to the target branch func runCheckout(cfg *config.Config, opts *checkoutOptions) error { - cfg.Warningf("gh stack checkout is not yet implemented") + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil + } + + var s *stack.Stack + var targetBranch string + + if opts.target == "" { + // Interactive picker mode + s, err = interactiveStackPicker(cfg, sf) + if err != nil { + cfg.Errorf("%s", err) + return nil + } + if s == nil { + return nil + } + // Check out the top active branch of the selected stack + if idx := s.FirstActiveBranchIndex(); idx >= 0 { + targetBranch = s.Branches[len(s.Branches)-1].Branch + } else { + targetBranch = s.Branches[len(s.Branches)-1].Branch + } + } else { + // Resolve target against local stacks + s, targetBranch, err = findStackByTarget(sf, opts.target) + if err != nil { + cfg.Errorf("%s", err) + return nil + } + } + + currentBranch, _ := git.CurrentBranch() + if targetBranch == currentBranch { + cfg.Infof("Already on %s", targetBranch) + cfg.Printf("Stack: %s", s.DisplayName()) + return nil + } + + if err := git.CheckoutBranch(targetBranch); err != nil { + cfg.Errorf("failed to checkout %s: %v", targetBranch, err) + return nil + } + + cfg.Successf("Switched to %s", targetBranch) + cfg.Printf("Stack: %s", s.DisplayName()) return nil } + +// findStackByTarget resolves a target string against locally tracked stacks. +// It tries PR number first (integer), then branch name. +func findStackByTarget(sf *stack.StackFile, target string) (*stack.Stack, string, error) { + // Try parsing as a PR number + if prNumber, err := strconv.Atoi(target); err == nil && prNumber > 0 { + s, b := sf.FindStackByPRNumber(prNumber) + if s != nil && b != nil { + return s, b.Branch, nil + } + } + + // Try matching as a branch name + stacks := sf.FindAllStacksForBranch(target) + if len(stacks) == 1 { + return stacks[0], target, nil + } + if len(stacks) > 1 { + // Target is in multiple stacks (e.g. a trunk branch) — return the first one. + // A future improvement could prompt for disambiguation here. + return stacks[0], target, nil + } + + return nil, "", fmt.Errorf( + "no locally tracked stack found for %q\n"+ + "This command currently only works with stacks created locally.\n"+ + "Server-side stack discovery will be available in a future release.", + target, + ) +} + +// interactiveStackPicker shows a menu of all locally tracked stacks and returns +// the one the user selects. Returns nil, nil if the user has no stacks. +func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) { + if !cfg.IsInteractive() { + return nil, fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack") + } + + if len(sf.Stacks) == 0 { + cfg.Infof("No locally tracked stacks found") + cfg.Printf("Create a stack with %s or check out a specific branch/PR once server-side discovery is available.", cfg.ColorCyan("gh stack init")) + return nil, nil + } + + options := make([]string, len(sf.Stacks)) + for i := range sf.Stacks { + options[i] = sf.Stacks[i].DisplayName() + } + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selected, err := p.Select( + "Select a stack to check out (showing locally tracked stacks only)", + "", + options, + ) + if err != nil { + return nil, fmt.Errorf("stack selection: %w", err) + } + + return &sf.Stacks[selected], nil +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 808db2e..5114c96 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -167,6 +167,20 @@ func (sf *StackFile) FindAllStacksForBranch(branch string) []*Stack { return stacks } +// FindStackByPRNumber returns the first stack and branch whose PR number matches. +// Returns nil, nil if no match is found. +func (sf *StackFile) FindStackByPRNumber(prNumber int) (*Stack, *BranchRef) { + for i := range sf.Stacks { + for j := range sf.Stacks[i].Branches { + b := &sf.Stacks[i].Branches[j] + if b.PullRequest != nil && b.PullRequest.Number == prNumber { + return &sf.Stacks[i], b + } + } + } + return nil, nil +} + // ValidateNoDuplicateBranch checks that the branch is not already in any stack. func (sf *StackFile) ValidateNoDuplicateBranch(branch string) error { for _, s := range sf.Stacks { From c1aa1f2aad59876a9942e0d2a4fa219c9e8333bd Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 13:30:06 -0400 Subject: [PATCH 33/78] abbreviated workflow to quickly add to stacks --- README.md | 80 +++++++++++++++++++++- cmd/add.go | 147 ++++++++++++++++++++++++++++++++++++++-- cmd/init.go | 53 +++++++++++++-- internal/branch/name.go | 140 ++++++++++++++++++++++++++++++++++++++ internal/git/git.go | 31 +++++++++ internal/stack/stack.go | 1 + 6 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 internal/branch/name.go diff --git a/README.md b/README.md index b30b32b..7e7d04e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Initialize a new stack in the current repository. gh stack init [branches...] [flags] ``` -Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. +Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix for auto-naming (unless adopting existing branches). When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. @@ -67,6 +67,7 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | | `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | +| `-p, --prefix ` | Set a branch name prefix for the stack | **Examples:** @@ -82,6 +83,9 @@ gh stack init --base develop feature-auth # Adopt existing branches into a stack gh stack init --adopt feature-auth feature-api + +# Set a prefix for auto-naming branches +gh stack init -p feat ``` ### `gh stack add` @@ -89,16 +93,47 @@ gh stack init --adopt feature-auth feature-api Add a new branch on top of the current stack. ``` -gh stack add [branch] +gh stack add [branch] [flags] ``` Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. +You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated. Auto-generated names use either numbered format (`prefix/01`, `prefix/02`) or date+slug format depending on prefix configuration and existing branch naming patterns. + +| Flag | Description | +|------|-------------| +| `-a, --all` | Stage all changes (including untracked files); requires `-m` | +| `-u, --update` | Stage changes to tracked files only; requires `-m` | +| `-m, --message ` | Create a commit with this message before creating the branch | + +> **Note:** `-a` and `-u` are mutually exclusive. + **Examples:** ```sh +# Create a branch by name gh stack add api-routes -gh stack add # prompts for name + +# Prompt for a branch name interactively +gh stack add + +# Stage all changes, commit, and auto-generate the branch name +gh stack add -am "Add login endpoint" + +# Stage only tracked files, commit, and auto-generate the branch name +gh stack add -um "Fix auth bug" + +# Commit already-staged changes and auto-generate the branch name +gh stack add -m "Add user model" + +# Stage all changes, commit, and use an explicit branch name +gh stack add -am "Add tests" test-layer + +# Stage only tracked files, commit, and use an explicit branch name +gh stack add -um "Update docs" docs-layer + +# Commit already-staged changes and use an explicit branch name +gh stack add -m "Refactor utils" cleanup-layer ``` ### `gh stack checkout` @@ -355,3 +390,42 @@ gh stack push # 8. When the first PR is merged, sync the stack gh stack sync ``` + +## Abbreviated workflow + +If you want to minimize keystrokes, use a branch prefix and the `-am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated from your commit messages. + +When a branch has no commits yet (e.g., right after `init`), `add -am` stages and commits directly on that branch instead of creating a new one. Once a branch has commits, `add -am` creates a new branch, checks it out, and commits there. + +```sh +# 1. Start a stack with a prefix +gh stack init -p feat +# → creates feat/01 and checks it out + +# 2. Write code for the first layer +# ... write code ... + +# 3. Stage and commit on the current branch +gh stack add -am "Auth middleware" +# → feat/01 has no commits yet, so the commit lands here +# (no new branch is created) + +# 4. Write code for the next layer +# ... write code ... + +# 5. Create the next branch and commit +gh stack add -am "API routes" +# → feat/01 already has commits, so a new branch feat/02 is +# created, checked out, and the commit lands there + +# 6. Keep going +# ... write code ... + +gh stack add -am "Frontend components" +# → feat/02 already has commits, creates feat/03 and commits there + +# 7. Push everything and create PRs +gh stack push +``` + +Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -am "..."` does it all. diff --git a/cmd/add.go b/cmd/add.go index bac671a..57f7c34 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,25 +3,58 @@ package cmd import ( "fmt" + "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) +type addOptions struct { + stageAll bool + stageTracked bool + message string +} + func AddCmd(cfg *config.Config) *cobra.Command { + opts := &addOptions{} + cmd := &cobra.Command{ Use: "add [branch]", Short: "Add a new branch on top of the current stack", - Args: cobra.MaximumNArgs(1), + Long: `Add a new branch on top of the current stack. + +Optionally stage changes and create a commit before creating the branch: + -a Stage all changes (including untracked) before committing + -u Stage tracked file changes before committing + -m Create a commit with the given message + +When -m is provided without an explicit branch name, the branch name +is auto-generated based on the commit message and stack prefix.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runAdd(cfg, args) + return runAdd(cfg, opts, args) }, } + + cmd.Flags().BoolVarP(&opts.stageAll, "all", "a", false, "Stage all changes including untracked files") + cmd.Flags().BoolVarP(&opts.stageTracked, "update", "u", false, "Stage changes to tracked files only") + cmd.Flags().StringVarP(&opts.message, "message", "m", "", "Create a commit with this message") + return cmd } -func runAdd(cfg *config.Config, args []string) error { +func runAdd(cfg *config.Config, opts *addOptions, args []string) error { + // Validate flag combinations + if opts.stageAll && opts.stageTracked { + cfg.Errorf("flags -a and -u are mutually exclusive") + return nil + } + if (opts.stageAll || opts.stageTracked) && opts.message == "" { + cfg.Errorf("staging flags (-a, -u) require -m to create a commit") + return nil + } + gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") @@ -69,14 +102,82 @@ func runAdd(cfg *config.Config, args []string) error { return nil } + // When -m is provided, check if the current branch is a stack branch with + // no unique commits relative to its parent. If so, the commit should land + // on this branch without creating a new one (e.g., right after init). + var branchIsEmpty bool + if opts.message != "" && idx >= 0 { + parentBranch := s.ActiveBaseBranch(currentBranch) + commits, _ := git.LogRange(parentBranch, currentBranch) + branchIsEmpty = len(commits) == 0 + } + + // Empty branch path: stage and commit here, don't create a new branch. + if branchIsEmpty && opts.message != "" { + if opts.stageAll { + if err := git.StageAll(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return nil + } + } else if opts.stageTracked { + if err := git.StageTracked(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return nil + } + } + if !git.HasStagedChanges() { + cfg.Errorf("nothing to commit; stage changes first or use -a/-u") + return nil + } + sha, err := git.Commit(opts.message) + if err != nil { + cfg.Errorf("failed to commit: %s", err) + return nil + } + cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha[:7]), currentBranch) + cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch) + cfg.Printf("When you're ready for the next layer, run %s again", cfg.ColorCyan("gh stack add")) + return nil + } + + // Resolve branch name var branchName string + var explicitName string if len(args) > 0 { - branchName = args[0] + explicitName = args[0] + } + + if opts.message != "" { + // Auto-naming mode + existingBranches := s.BranchNames() + isFirstBranch := len(existingBranches) == 0 + name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, isFirstBranch) + if name == "" { + cfg.Errorf("could not generate branch name") + return nil + } + branchName = name + if info != "" { + cfg.Infof("%s", info) + } + } else if explicitName != "" { + // No -m, but explicit name given + if s.Prefix != "" { + branchName = s.Prefix + "/" + explicitName + cfg.Infof("Branch name prefixed: %s", branchName) + } else { + branchName = explicitName + } } else { + // No -m, no explicit name — prompt fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ") if _, err := fmt.Fscan(cfg.In, &branchName); err != nil { return fmt.Errorf("could not read branch name: %w", err) } + if s.Prefix != "" && branchName != "" { + branchName = s.Prefix + "/" + branchName + cfg.Infof("Branch name prefixed: %s", branchName) + } } if branchName == "" { @@ -90,10 +191,11 @@ func runAdd(cfg *config.Config, args []string) error { } if git.BranchExists(branchName) { - cfg.Errorf("branch %q already exists", branchName) + cfg.Errorf("branch %q already exists; provide an explicit name", branchName) return nil } + // Create the new branch from the current HEAD and check it out if err := git.CreateBranch(branchName, currentBranch); err != nil { cfg.Errorf("failed to create branch: %s", err) return nil @@ -107,11 +209,44 @@ func runAdd(cfg *config.Config, args []string) error { base, _ := git.HeadSHA(currentBranch) s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base}) + // Stage and commit on the NEW branch if -m is provided + var commitSHA string + if opts.message != "" { + if opts.stageAll { + if err := git.StageAll(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return nil + } + } else if opts.stageTracked { + if err := git.StageTracked(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return nil + } + } + if !git.HasStagedChanges() { + cfg.Errorf("nothing to commit; stage changes first or use -a/-u") + return nil + } + sha, err := git.Commit(opts.message) + if err != nil { + cfg.Errorf("failed to commit: %s", err) + return nil + } + commitSHA = sha[:7] + } + if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) return nil } - cfg.Successf("Created and checked out branch %q", branchName) + // Print summary + position := len(s.Branches) + if commitSHA != "" { + cfg.Successf("Created branch %s (layer %d) with commit %s", cfg.ColorBold(branchName), position, commitSHA) + } else { + cfg.Successf("Created and checked out branch %q", branchName) + } + return nil } diff --git a/cmd/init.go b/cmd/init.go index 048a46a..587b0e0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,8 +2,10 @@ package cmd import ( "fmt" + "strings" "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" @@ -14,6 +16,7 @@ type initOptions struct { branches []string base string adopt bool + prefix string } func InitCmd(cfg *config.Config) *cobra.Command { @@ -39,6 +42,7 @@ Trunk defaults to default branch, unless specified otherwise.`, cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack") + cmd.Flags().StringVarP(&opts.prefix, "prefix", "p", "", "Branch name prefix for the stack") return cmd } @@ -79,11 +83,15 @@ func runInit(cfg *config.Config, opts *initOptions) error { currentBranch, _ := git.CurrentBranch() - // Don't allow initializing a stack if the current branch is already part of another stack + // Don't allow initializing a stack if the current branch is a non-trunk + // member of another stack. Trunk branches (e.g. "main") can be shared + // across multiple stacks. if currentBranch != "" { - if stacks := sf.FindAllStacksForBranch(currentBranch); len(stacks) > 0 { - cfg.Errorf("current branch %q is already part of a stack", currentBranch) - return nil + for _, s := range sf.FindAllStacksForBranch(currentBranch) { + if s.IndexOf(currentBranch) >= 0 { + cfg.Errorf("current branch %q is already part of a stack", currentBranch) + return nil + } } } @@ -129,6 +137,17 @@ func runInit(cfg *config.Config, opts *initOptions) error { } p := prompter.New(cfg.In, cfg.Out, cfg.Err) + // Step 1: Ask for prefix + if opts.prefix == "" { + prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "") + if err != nil { + cfg.Errorf("failed to read prefix: %s", err) + return nil + } + opts.prefix = strings.TrimSpace(prefixInput) + } + + // Step 2: Ask for branch name if currentBranch != "" && currentBranch != trunk { // Already on a non-trunk branch — offer to use it useCurrentBranch, err := p.Confirm( @@ -149,15 +168,28 @@ func runInit(cfg *config.Config, opts *initOptions) error { } if len(branches) == 0 { - branchName, err := p.Input("What branch would you like to use as the first layer of your stack?", "") + prompt := "What branch would you like to use as the first layer of your stack?" + if opts.prefix != "" { + prompt = fmt.Sprintf("Name the first branch, or leave blank to use %s", branch.NextNumberedName(opts.prefix, nil)) + } + branchName, err := p.Input(prompt, "") if err != nil { cfg.Errorf("failed to read branch name: %s", err) return nil } - if branchName == "" { + branchName = strings.TrimSpace(branchName) + + if branchName == "" && opts.prefix != "" { + // Auto-generate numbered branch name + branchName = branch.NextNumberedName(opts.prefix, nil) + } else if branchName == "" { cfg.Errorf("branch name cannot be empty") return nil + } else if opts.prefix != "" { + // Prepend prefix to the user-provided name + branchName = opts.prefix + "/" + branchName } + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { cfg.Errorf("branch %q already exists in a stack", branchName) return nil @@ -172,6 +204,14 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } + // Validate prefix (from flag or interactive input) + if opts.prefix != "" { + if err := git.ValidateRefName(opts.prefix); err != nil { + cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix) + return nil + } + } + // Build stack trunkSHA, _ := git.HeadSHA(trunk) branchRefs := make([]stack.BranchRef, len(branches)) @@ -185,6 +225,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } newStack := stack.Stack{ + Prefix: opts.prefix, Trunk: stack.BranchRef{ Branch: trunk, Head: trunkSHA, diff --git a/internal/branch/name.go b/internal/branch/name.go new file mode 100644 index 0000000..8ebcd42 --- /dev/null +++ b/internal/branch/name.go @@ -0,0 +1,140 @@ +package branch + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +var ( + nonAlphanumRe = regexp.MustCompile(`[^a-z0-9-]+`) + multiHyphenRe = regexp.MustCompile(`-{2,}`) + numberedBranchRe = regexp.MustCompile(`/(\d+)$`) +) + +// Slugify converts a message into a URL/branch-safe slug. +// Lowercases, replaces special chars with hyphens, strips consecutive hyphens, +// and truncates to ~50 chars at a word boundary. +func Slugify(message string) string { + // Normalize unicode and lowercase + s := strings.ToLower(norm.NFKD.String(message)) + + // Strip non-ASCII diacritics (combining marks) + var b strings.Builder + for _, r := range s { + if !unicode.Is(unicode.Mn, r) { // Mn = nonspacing marks + b.WriteRune(r) + } + } + s = b.String() + + // Replace non-alphanumeric chars with hyphens + s = nonAlphanumRe.ReplaceAllString(s, "-") + + // Collapse consecutive hyphens + s = multiHyphenRe.ReplaceAllString(s, "-") + + // Trim leading/trailing hyphens + s = strings.Trim(s, "-") + + // Truncate to ~50 chars at word boundary + if len(s) > 50 { + s = s[:50] + if idx := strings.LastIndex(s, "-"); idx > 0 { + s = s[:idx] + } + } + + return s +} + +// DateSlug returns a branch name in the format YYYY-MM-DD-slugified-message. +func DateSlug(message string) string { + date := time.Now().Format("2006-01-02") + slug := Slugify(message) + if slug == "" { + return date + } + return date + "-" + slug +} + +// FollowsNumbering returns true if branchName matches the pattern {prefix}/\d+. +func FollowsNumbering(prefix, branchName string) bool { + if !strings.HasPrefix(branchName, prefix+"/") { + return false + } + suffix := branchName[len(prefix)+1:] + _, err := strconv.Atoi(suffix) + return err == nil +} + +// NextNumberedName scans existingBranches for the highest number matching +// {prefix}/NN and returns {prefix}/{next} with zero-padded two digits. +func NextNumberedName(prefix string, existingBranches []string) string { + maxNum := 0 + for _, b := range existingBranches { + if m := numberedBranchRe.FindStringSubmatch(b); m != nil { + if strings.HasPrefix(b, prefix+"/") { + n, _ := strconv.Atoi(m[1]) + if n > maxNum { + maxNum = n + } + } + } + } + return fmt.Sprintf("%s/%02d", prefix, maxNum+1) +} + +// ResolveBranchName implements the full decision tree for branch name generation. +// +// Parameters: +// - prefix: configured stack prefix (may be empty) +// - message: commit message (from -m flag; may be empty if not using auto-naming) +// - explicitName: branch name provided as argument (may be empty) +// - existingBranches: current branch names in the stack +// - isFirstBranch: true if this is the first branch being added to the stack +// +// Returns the resolved branch name and an informational message (may be empty). +func ResolveBranchName(prefix, message, explicitName string, existingBranches []string, isFirstBranch bool) (name string, info string) { + if explicitName != "" { + // Explicit name provided + if prefix != "" { + name = prefix + "/" + explicitName + info = fmt.Sprintf("Branch name prefixed: %s", name) + } else { + name = explicitName + } + return + } + + // Auto-generate from message + if message == "" { + return "", "" + } + + if prefix != "" { + // Check if we should use numbered format + useNumbering := isFirstBranch + if !useNumbering && len(existingBranches) > 0 { + lastBranch := existingBranches[len(existingBranches)-1] + useNumbering = FollowsNumbering(prefix, lastBranch) + } + + if useNumbering { + name = NextNumberedName(prefix, existingBranches) + } else { + name = prefix + "/" + DateSlug(message) + info = "Branch name auto-generated using date+slug format because existing branches don't follow numbering convention" + } + } else { + // No prefix — always use date+slug + name = DateSlug(message) + } + + return +} diff --git a/internal/git/git.go b/internal/git/git.go index 5cd0c42..0423c6d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -446,3 +446,34 @@ func MergeFF(target string) error { func UpdateBranchRef(branch, sha string) error { return runSilent("branch", "-f", branch, sha) } + +// StageAll stages all changes including untracked files (git add -A). +func StageAll() error { + return runSilent("add", "-A") +} + +// StageTracked stages changes to tracked files only (git add -u). +func StageTracked() error { + return runSilent("add", "-u") +} + +// HasStagedChanges returns true if there are staged changes ready to commit. +func HasStagedChanges() bool { + err := runSilent("diff", "--cached", "--quiet") + // Exit code 1 means there are differences (staged changes exist). + return err != nil +} + +// Commit creates a commit with the given message and returns the new HEAD SHA. +func Commit(message string) (string, error) { + if err := runSilent("commit", "-m", message); err != nil { + return "", err + } + return run("rev-parse", "HEAD") +} + +// ValidateRefName checks whether name is a valid git branch name. +func ValidateRefName(name string) error { + _, err := run("check-ref-format", "--branch", name) + return err +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 5114c96..fde119a 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -35,6 +35,7 @@ type BranchRef struct { // Stack represents a single stack of branches. type Stack struct { ID string `json:"id,omitempty"` + Prefix string `json:"prefix,omitempty"` Trunk BranchRef `json:"trunk"` Branches []BranchRef `json:"branches"` } From c2ddf60fb1fdd8f48af017b2e5602237f0b203d4 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 14:02:10 -0400 Subject: [PATCH 34/78] Add testable interfaces for git and github packages Introduce Ops interface and MockOps for git operations, and ClientOps interface with MockClient for GitHub API operations. This enables command-level tests to inject mocks without changing production code. - git.Ops interface wraps all 34 public git functions - Package-level functions now delegate through a swappable ops variable - SetOps()/CurrentOps() allow tests to inject MockOps - github.ClientOps interface covers all Client methods - MockClient provides configurable function-field test doubles - All defaults return zero values; no-op for error returns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/git/git.go | 331 +++++------------------ internal/git/gitops.go | 393 ++++++++++++++++++++++++++++ internal/git/mock_ops.go | 281 ++++++++++++++++++++ internal/github/client_interface.go | 16 ++ internal/github/mock_client.go | 60 +++++ 5 files changed, 818 insertions(+), 263 deletions(-) create mode 100644 internal/git/gitops.go create mode 100644 internal/git/mock_ops.go create mode 100644 internal/github/client_interface.go create mode 100644 internal/github/mock_client.go diff --git a/internal/git/git.go b/internal/git/git.go index 0423c6d..2c98008 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,11 +2,8 @@ package git import ( "context" - "errors" "os" "os/exec" - "path/filepath" - "strconv" "strings" "time" @@ -45,86 +42,92 @@ func runSilent(args ...string) error { return cmd.Run() } -// --- Delegated to cligit.Client --- +// rebaseContinueOnce runs a single git rebase --continue without auto-resolve. +func rebaseContinueOnce() error { + cmd := exec.Command("git", "rebase", "--continue") + cmd.Env = append(os.Environ(), "GIT_EDITOR=true") + return cmd.Run() +} + +// tryAutoResolveRebase checks whether rerere has resolved all conflicts +// from a failed rebase. If so, it auto-continues the rebase (potentially +// multiple times for multi-commit rebases). Returns originalErr if any +// conflicts remain that need manual resolution. +func tryAutoResolveRebase(originalErr error) error { + for i := 0; i < 1000; i++ { + if !IsRebaseInProgress() { + return nil + } + conflicts, err := ConflictedFiles() + if err != nil { + return originalErr + } + if len(conflicts) > 0 { + return originalErr + } + // Rerere resolved all conflicts — auto-continue. + if rebaseContinueOnce() == nil { + return nil + } + // Continue hit another conflicting commit; loop to check + // if rerere resolved that one too. + } + return originalErr +} + +// --- Public functions delegate through the ops interface --- // GitDir returns the path to the .git directory. func GitDir() (string, error) { - return client.GitDir(context.Background()) + return ops.GitDir() } // CurrentBranch returns the name of the current branch. func CurrentBranch() (string, error) { - return client.CurrentBranch(context.Background()) + return ops.CurrentBranch() } // BranchExists returns whether a local branch with the given name exists. func BranchExists(name string) bool { - return client.HasLocalBranch(context.Background(), name) + return ops.BranchExists(name) } // CheckoutBranch switches to the specified branch. func CheckoutBranch(name string) error { - return client.CheckoutBranch(context.Background(), name) + return ops.CheckoutBranch(name) } // Fetch fetches from the given remote. func Fetch(remote string) error { - return client.Fetch(context.Background(), remote, "") + return ops.Fetch(remote) } -// --- Custom operations not available in cligit --- - // DefaultBranch returns the HEAD branch from origin. func DefaultBranch() (string, error) { - ref, err := run("symbolic-ref", "refs/remotes/origin/HEAD") - // fallback: if origin/HEAD doesn't exist, look for common default branch names - if err != nil { - for _, name := range []string{"main", "master"} { - if BranchExists(name) { - return name, nil - } - } - return "", err - } - return strings.TrimPrefix(ref, "refs/remotes/origin/"), nil + return ops.DefaultBranch() } // CreateBranch creates a new branch from the given base. func CreateBranch(name, base string) error { - return runSilent("branch", name, base) + return ops.CreateBranch(name, base) } // Push pushes branches to a remote with optional force and atomic flags. func Push(remote string, branches []string, force, atomic bool) error { - args := []string{"push", remote} - if force { - args = append(args, "--force-with-lease") - } - if atomic { - args = append(args, "--atomic") - } - args = append(args, branches...) - return runSilent(args...) + return ops.Push(remote, branches, force, atomic) } // Rebase rebases the current branch onto the given base. // If rerere resolves all conflicts automatically, the rebase continues // without user intervention. func Rebase(base string) error { - err := runSilent("rebase", base) - if err == nil { - return nil - } - return tryAutoResolveRebase(err) + return ops.Rebase(base) } // EnableRerere enables git rerere (reuse recorded resolution) and // rerere.autoupdate (auto-stage resolved files) for the repository. func EnableRerere() error { - if err := runSilent("config", "rerere.enabled", "true"); err != nil { - return err - } - return runSilent("config", "rerere.autoupdate", "true") + return ops.EnableRerere() } // RebaseOnto rebases a branch using the three-argument form: @@ -137,11 +140,7 @@ func EnableRerere() error { // If rerere resolves all conflicts automatically, the rebase continues // without user intervention. func RebaseOnto(newBase, oldBase, branch string) error { - err := runSilent("rebase", "--onto", newBase, oldBase, branch) - if err == nil { - return nil - } - return tryAutoResolveRebase(err) + return ops.RebaseOnto(newBase, oldBase, branch) } // RebaseContinue continues an in-progress rebase. @@ -150,76 +149,22 @@ func RebaseOnto(newBase, oldBase, branch string) error { // If rerere resolves subsequent conflicts automatically, the rebase continues // without user intervention. func RebaseContinue() error { - err := rebaseContinueOnce() - if err == nil { - return nil - } - return tryAutoResolveRebase(err) -} - -// rebaseContinueOnce runs a single git rebase --continue without auto-resolve. -func rebaseContinueOnce() error { - cmd := exec.Command("git", "rebase", "--continue") - cmd.Env = append(os.Environ(), "GIT_EDITOR=true") - return cmd.Run() -} - -// tryAutoResolveRebase checks whether rerere has resolved all conflicts -// from a failed rebase. If so, it auto-continues the rebase (potentially -// multiple times for multi-commit rebases). Returns originalErr if any -// conflicts remain that need manual resolution. -func tryAutoResolveRebase(originalErr error) error { - for i := 0; i < 1000; i++ { - if !IsRebaseInProgress() { - return nil - } - conflicts, err := ConflictedFiles() - if err != nil { - return originalErr - } - if len(conflicts) > 0 { - return originalErr - } - // Rerere resolved all conflicts — auto-continue. - if rebaseContinueOnce() == nil { - return nil - } - // Continue hit another conflicting commit; loop to check - // if rerere resolved that one too. - } - return originalErr + return ops.RebaseContinue() } // RebaseAbort aborts an in-progress rebase. func RebaseAbort() error { - return runSilent("rebase", "--abort") + return ops.RebaseAbort() } // IsRebaseInProgress checks whether a rebase is currently in progress. func IsRebaseInProgress() bool { - gitDir, err := GitDir() - if err != nil { - return false - } - for _, dir := range []string{"rebase-merge", "rebase-apply"} { - rebasePath := filepath.Join(gitDir, dir) - if info, err := os.Stat(rebasePath); err == nil && info.IsDir() { - return true - } - } - return false + return ops.IsRebaseInProgress() } // ConflictedFiles returns the list of files that have merge conflicts. func ConflictedFiles() ([]string, error) { - output, err := run("diff", "--name-only", "--diff-filter=U") - if err != nil { - return nil, err - } - if output == "" { - return nil, nil - } - return strings.Split(output, "\n"), nil + return ops.ConflictedFiles() } // ConflictMarkerInfo holds the location of conflict markers in a file. @@ -236,148 +181,39 @@ type ConflictSection struct { // FindConflictMarkers scans a file for conflict markers and returns their locations. func FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { - output, err := run("diff", "--check", "--", filePath) - // git diff --check exits non-zero when conflicts exist, so we parse even on error - if output == "" && err != nil { - return nil, err - } - - info := &ConflictMarkerInfo{File: filePath} - var currentSection *ConflictSection - - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Format: "filename:lineno: leftover conflict marker" - parts := strings.SplitN(line, ":", 3) - if len(parts) < 3 { - continue - } - lineNo, parseErr := strconv.Atoi(strings.TrimSpace(parts[1])) - if parseErr != nil { - continue - } - marker := strings.TrimSpace(parts[2]) - if strings.Contains(marker, "leftover conflict marker") { - if currentSection == nil || currentSection.EndLine != 0 { - currentSection = &ConflictSection{StartLine: lineNo} - info.Sections = append(info.Sections, *currentSection) - } - // Update the end line of the last section - info.Sections[len(info.Sections)-1].EndLine = lineNo - } - } - - return info, nil + return ops.FindConflictMarkers(filePath) } // IsAncestor returns whether ancestor is an ancestor of descendant. // This is useful to check if a fast-forward merge is possible. func IsAncestor(ancestor, descendant string) (bool, error) { - err := runSilent("merge-base", "--is-ancestor", ancestor, descendant) - if err == nil { - return true, nil - } - // Exit code 1 means "not an ancestor", which is not an error condition. - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { - return false, nil - } - return false, err + return ops.IsAncestor(ancestor, descendant) } // HeadSHA returns the full SHA of the given ref. func HeadSHA(ref string) (string, error) { - return run("rev-parse", ref) + return ops.HeadSHA(ref) } // MergeBase returns the best common ancestor commit between two refs. func MergeBase(a, b string) (string, error) { - return run("merge-base", a, b) + return ops.MergeBase(a, b) } // Log returns recent commits for the given branch. func Log(ref string, maxCount int) ([]CommitInfo, error) { - format := "%H\t%s\t%at" - output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) - if err != nil { - return nil, err - } - if output == "" { - return nil, nil - } - - var commits []CommitInfo - for _, line := range strings.Split(output, "\n") { - parts := strings.SplitN(line, "\t", 3) - if len(parts) < 3 { - continue - } - ts, _ := strconv.ParseInt(parts[2], 10, 64) - commits = append(commits, CommitInfo{ - SHA: parts[0], - Subject: parts[1], - Time: time.Unix(ts, 0), - }) - } - return commits, nil + return ops.Log(ref, maxCount) } // LogRange returns commits in the range base..head (commits reachable from head // but not from base). This is useful for seeing all commits unique to a branch. func LogRange(base, head string) ([]CommitInfo, error) { - format := "%H\t%s\t%at" - rangeSpec := base + ".." + head - output, err := run("log", rangeSpec, "--format="+format) - if err != nil { - return nil, err - } - if output == "" { - return nil, nil - } - - var commits []CommitInfo - for _, line := range strings.Split(output, "\n") { - parts := strings.SplitN(line, "\t", 3) - if len(parts) < 3 { - continue - } - ts, _ := strconv.ParseInt(parts[2], 10, 64) - commits = append(commits, CommitInfo{ - SHA: parts[0], - Subject: parts[1], - Time: time.Unix(ts, 0), - }) - } - return commits, nil + return ops.LogRange(base, head) } // DiffStatRange returns the total additions and deletions between two refs. func DiffStatRange(base, head string) (additions, deletions int, err error) { - output, err := run("diff", "--numstat", base+".."+head) - if err != nil { - return 0, 0, err - } - if output == "" { - return 0, 0, nil - } - for _, line := range strings.Split(output, "\n") { - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - // Binary files show "-" instead of numbers - if parts[0] == "-" { - continue - } - a, _ := strconv.Atoi(parts[0]) - d, _ := strconv.Atoi(parts[1]) - additions += a - deletions += d - } - return additions, deletions, nil + return ops.DiffStatRange(base, head) } // FileDiffStat holds per-file diff statistics. @@ -389,91 +225,60 @@ type FileDiffStat struct { // DiffStatFiles returns per-file additions and deletions between two refs. func DiffStatFiles(base, head string) ([]FileDiffStat, error) { - output, err := run("diff", "--numstat", base+".."+head) - if err != nil { - return nil, err - } - if output == "" { - return nil, nil - } - var files []FileDiffStat - for _, line := range strings.Split(output, "\n") { - parts := strings.Fields(line) - if len(parts) < 3 { - continue - } - a, _ := strconv.Atoi(parts[0]) - d, _ := strconv.Atoi(parts[1]) - files = append(files, FileDiffStat{ - Path: parts[2], - Additions: a, - Deletions: d, - }) - } - return files, nil + return ops.DiffStatFiles(base, head) } // DeleteBranch deletes a local branch. func DeleteBranch(name string, force bool) error { - flag := "-d" - if force { - flag = "-D" - } - return runSilent("branch", flag, name) + return ops.DeleteBranch(name, force) } // DeleteRemoteBranch deletes a branch on the remote. func DeleteRemoteBranch(remote, branch string) error { - return runSilent("push", remote, "--delete", branch) + return ops.DeleteRemoteBranch(remote, branch) } // ResetHard resets the current branch to the given ref. func ResetHard(ref string) error { - return runSilent("reset", "--hard", ref) + return ops.ResetHard(ref) } // SetUpstreamTracking sets the upstream tracking branch. func SetUpstreamTracking(branch, remote string) error { - return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) + return ops.SetUpstreamTracking(branch, remote) } // MergeFF fast-forwards the currently checked-out branch using a merge. func MergeFF(target string) error { - return runSilent("merge", "--ff-only", target) + return ops.MergeFF(target) } // UpdateBranchRef moves a branch pointer to a new commit (for branches not currently checked out). func UpdateBranchRef(branch, sha string) error { - return runSilent("branch", "-f", branch, sha) + return ops.UpdateBranchRef(branch, sha) } // StageAll stages all changes including untracked files (git add -A). func StageAll() error { - return runSilent("add", "-A") + return ops.StageAll() } // StageTracked stages changes to tracked files only (git add -u). func StageTracked() error { - return runSilent("add", "-u") + return ops.StageTracked() } // HasStagedChanges returns true if there are staged changes ready to commit. func HasStagedChanges() bool { - err := runSilent("diff", "--cached", "--quiet") - // Exit code 1 means there are differences (staged changes exist). - return err != nil + return ops.HasStagedChanges() } // Commit creates a commit with the given message and returns the new HEAD SHA. func Commit(message string) (string, error) { - if err := runSilent("commit", "-m", message); err != nil { - return "", err - } - return run("rev-parse", "HEAD") + return ops.Commit(message) } // ValidateRefName checks whether name is a valid git branch name. func ValidateRefName(name string) error { - _, err := run("check-ref-format", "--branch", name) - return err + return ops.ValidateRefName(name) } diff --git a/internal/git/gitops.go b/internal/git/gitops.go new file mode 100644 index 0000000..97d78ae --- /dev/null +++ b/internal/git/gitops.go @@ -0,0 +1,393 @@ +package git + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Ops defines the interface for git operations used by commands. +// The package-level functions are the default production implementation. +// Tests can substitute a mock via SetOps(). +type Ops interface { + GitDir() (string, error) + CurrentBranch() (string, error) + BranchExists(name string) bool + CheckoutBranch(name string) error + Fetch(remote string) error + DefaultBranch() (string, error) + CreateBranch(name, base string) error + Push(remote string, branches []string, force, atomic bool) error + Rebase(base string) error + EnableRerere() error + RebaseOnto(newBase, oldBase, branch string) error + RebaseContinue() error + RebaseAbort() error + IsRebaseInProgress() bool + ConflictedFiles() ([]string, error) + FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) + IsAncestor(ancestor, descendant string) (bool, error) + HeadSHA(ref string) (string, error) + MergeBase(a, b string) (string, error) + Log(ref string, maxCount int) ([]CommitInfo, error) + LogRange(base, head string) ([]CommitInfo, error) + DiffStatRange(base, head string) (additions, deletions int, err error) + DiffStatFiles(base, head string) ([]FileDiffStat, error) + DeleteBranch(name string, force bool) error + DeleteRemoteBranch(remote, branch string) error + ResetHard(ref string) error + SetUpstreamTracking(branch, remote string) error + MergeFF(target string) error + UpdateBranchRef(branch, sha string) error + StageAll() error + StageTracked() error + HasStagedChanges() bool + Commit(message string) (string, error) + ValidateRefName(name string) error +} + +// defaultOps implements Ops by delegating to the real git client and helpers. +type defaultOps struct{} + +var _ Ops = (*defaultOps)(nil) + +// ops is the current implementation. Tests replace this via SetOps(). +var ops Ops = &defaultOps{} + +// SetOps replaces the git operations implementation. Returns a restore function. +func SetOps(o Ops) func() { + old := ops + ops = o + return func() { ops = old } +} + +// CurrentOps returns the current Ops implementation. +func CurrentOps() Ops { + return ops +} + +// --- defaultOps method implementations --- + +func (d *defaultOps) GitDir() (string, error) { + return client.GitDir(context.Background()) +} + +func (d *defaultOps) CurrentBranch() (string, error) { + return client.CurrentBranch(context.Background()) +} + +func (d *defaultOps) BranchExists(name string) bool { + return client.HasLocalBranch(context.Background(), name) +} + +func (d *defaultOps) CheckoutBranch(name string) error { + return client.CheckoutBranch(context.Background(), name) +} + +func (d *defaultOps) Fetch(remote string) error { + return client.Fetch(context.Background(), remote, "") +} + +func (d *defaultOps) DefaultBranch() (string, error) { + ref, err := run("symbolic-ref", "refs/remotes/origin/HEAD") + if err != nil { + for _, name := range []string{"main", "master"} { + if BranchExists(name) { + return name, nil + } + } + return "", err + } + return strings.TrimPrefix(ref, "refs/remotes/origin/"), nil +} + +func (d *defaultOps) CreateBranch(name, base string) error { + return runSilent("branch", name, base) +} + +func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) error { + args := []string{"push", remote} + if force { + args = append(args, "--force-with-lease") + } + if atomic { + args = append(args, "--atomic") + } + args = append(args, branches...) + return runSilent(args...) +} + +func (d *defaultOps) Rebase(base string) error { + err := runSilent("rebase", base) + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +func (d *defaultOps) EnableRerere() error { + if err := runSilent("config", "rerere.enabled", "true"); err != nil { + return err + } + return runSilent("config", "rerere.autoupdate", "true") +} + +func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string) error { + err := runSilent("rebase", "--onto", newBase, oldBase, branch) + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +func (d *defaultOps) RebaseContinue() error { + err := rebaseContinueOnce() + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +func (d *defaultOps) RebaseAbort() error { + return runSilent("rebase", "--abort") +} + +func (d *defaultOps) IsRebaseInProgress() bool { + gitDir, err := GitDir() + if err != nil { + return false + } + for _, dir := range []string{"rebase-merge", "rebase-apply"} { + rebasePath := filepath.Join(gitDir, dir) + if info, err := os.Stat(rebasePath); err == nil && info.IsDir() { + return true + } + } + return false +} + +func (d *defaultOps) ConflictedFiles() ([]string, error) { + output, err := run("diff", "--name-only", "--diff-filter=U") + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + return strings.Split(output, "\n"), nil +} + +func (d *defaultOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { + output, err := run("diff", "--check", "--", filePath) + if output == "" && err != nil { + return nil, err + } + + info := &ConflictMarkerInfo{File: filePath} + var currentSection *ConflictSection + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + lineNo, parseErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if parseErr != nil { + continue + } + marker := strings.TrimSpace(parts[2]) + if strings.Contains(marker, "leftover conflict marker") { + if currentSection == nil || currentSection.EndLine != 0 { + currentSection = &ConflictSection{StartLine: lineNo} + info.Sections = append(info.Sections, *currentSection) + } + info.Sections[len(info.Sections)-1].EndLine = lineNo + } + } + + return info, nil +} + +func (d *defaultOps) IsAncestor(ancestor, descendant string) (bool, error) { + err := runSilent("merge-base", "--is-ancestor", ancestor, descendant) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err +} + +func (d *defaultOps) HeadSHA(ref string) (string, error) { + return run("rev-parse", ref) +} + +func (d *defaultOps) MergeBase(a, b string) (string, error) { + return run("merge-base", a, b) +} + +func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { + format := "%H\t%s\t%at" + output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(parts[2], 10, 64) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: parts[1], + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} + +func (d *defaultOps) LogRange(base, head string) ([]CommitInfo, error) { + format := "%H\t%s\t%at" + rangeSpec := base + ".." + head + output, err := run("log", rangeSpec, "--format="+format) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(parts[2], 10, 64) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: parts[1], + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} + +func (d *defaultOps) DiffStatRange(base, head string) (additions, deletions int, err error) { + output, err := run("diff", "--numstat", base+".."+head) + if err != nil { + return 0, 0, err + } + if output == "" { + return 0, 0, nil + } + for _, line := range strings.Split(output, "\n") { + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + if parts[0] == "-" { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + additions += a + deletions += d + } + return additions, deletions, nil +} + +func (d *defaultOps) DiffStatFiles(base, head string) ([]FileDiffStat, error) { + output, err := run("diff", "--numstat", base+".."+head) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + var files []FileDiffStat + for _, line := range strings.Split(output, "\n") { + parts := strings.Fields(line) + if len(parts) < 3 { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + files = append(files, FileDiffStat{ + Path: parts[2], + Additions: a, + Deletions: d, + }) + } + return files, nil +} + +func (d *defaultOps) DeleteBranch(name string, force bool) error { + flag := "-d" + if force { + flag = "-D" + } + return runSilent("branch", flag, name) +} + +func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error { + return runSilent("push", remote, "--delete", branch) +} + +func (d *defaultOps) ResetHard(ref string) error { + return runSilent("reset", "--hard", ref) +} + +func (d *defaultOps) SetUpstreamTracking(branch, remote string) error { + return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) +} + +func (d *defaultOps) MergeFF(target string) error { + return runSilent("merge", "--ff-only", target) +} + +func (d *defaultOps) UpdateBranchRef(branch, sha string) error { + return runSilent("branch", "-f", branch, sha) +} + +func (d *defaultOps) StageAll() error { + return runSilent("add", "-A") +} + +func (d *defaultOps) StageTracked() error { + return runSilent("add", "-u") +} + +func (d *defaultOps) HasStagedChanges() bool { + err := runSilent("diff", "--cached", "--quiet") + return err != nil +} + +func (d *defaultOps) Commit(message string) (string, error) { + if err := runSilent("commit", "-m", message); err != nil { + return "", err + } + return run("rev-parse", "HEAD") +} + +func (d *defaultOps) ValidateRefName(name string) error { + _, err := run("check-ref-format", "--branch", name) + return err +} diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go new file mode 100644 index 0000000..906bc3d --- /dev/null +++ b/internal/git/mock_ops.go @@ -0,0 +1,281 @@ +package git + +// MockOps is a test double for git operations. +// Each field is an optional function that, when set, handles the corresponding +// Ops method call. When nil, a reasonable default is returned. +type MockOps struct { + GitDirFn func() (string, error) + CurrentBranchFn func() (string, error) + BranchExistsFn func(string) bool + CheckoutBranchFn func(string) error + FetchFn func(string) error + DefaultBranchFn func() (string, error) + CreateBranchFn func(string, string) error + PushFn func(string, []string, bool, bool) error + RebaseFn func(string) error + EnableRerereFn func() error + RebaseOntoFn func(string, string, string) error + RebaseContinueFn func() error + RebaseAbortFn func() error + IsRebaseInProgressFn func() bool + ConflictedFilesFn func() ([]string, error) + FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) + IsAncestorFn func(string, string) (bool, error) + HeadSHAFn func(string) (string, error) + MergeBaseFn func(string, string) (string, error) + LogFn func(string, int) ([]CommitInfo, error) + LogRangeFn func(string, string) ([]CommitInfo, error) + DiffStatRangeFn func(string, string) (int, int, error) + DiffStatFilesFn func(string, string) ([]FileDiffStat, error) + DeleteBranchFn func(string, bool) error + DeleteRemoteBranchFn func(string, string) error + ResetHardFn func(string) error + SetUpstreamTrackingFn func(string, string) error + MergeFFFn func(string) error + UpdateBranchRefFn func(string, string) error + StageAllFn func() error + StageTrackedFn func() error + HasStagedChangesFn func() bool + CommitFn func(string) (string, error) + ValidateRefNameFn func(string) error +} + +var _ Ops = (*MockOps)(nil) + +func (m *MockOps) GitDir() (string, error) { + if m.GitDirFn != nil { + return m.GitDirFn() + } + return "/tmp/fake-git-dir", nil +} + +func (m *MockOps) CurrentBranch() (string, error) { + if m.CurrentBranchFn != nil { + return m.CurrentBranchFn() + } + return "main", nil +} + +func (m *MockOps) BranchExists(name string) bool { + if m.BranchExistsFn != nil { + return m.BranchExistsFn(name) + } + return false +} + +func (m *MockOps) CheckoutBranch(name string) error { + if m.CheckoutBranchFn != nil { + return m.CheckoutBranchFn(name) + } + return nil +} + +func (m *MockOps) Fetch(remote string) error { + if m.FetchFn != nil { + return m.FetchFn(remote) + } + return nil +} + +func (m *MockOps) DefaultBranch() (string, error) { + if m.DefaultBranchFn != nil { + return m.DefaultBranchFn() + } + return "main", nil +} + +func (m *MockOps) CreateBranch(name, base string) error { + if m.CreateBranchFn != nil { + return m.CreateBranchFn(name, base) + } + return nil +} + +func (m *MockOps) Push(remote string, branches []string, force, atomic bool) error { + if m.PushFn != nil { + return m.PushFn(remote, branches, force, atomic) + } + return nil +} + +func (m *MockOps) Rebase(base string) error { + if m.RebaseFn != nil { + return m.RebaseFn(base) + } + return nil +} + +func (m *MockOps) EnableRerere() error { + if m.EnableRerereFn != nil { + return m.EnableRerereFn() + } + return nil +} + +func (m *MockOps) RebaseOnto(newBase, oldBase, branch string) error { + if m.RebaseOntoFn != nil { + return m.RebaseOntoFn(newBase, oldBase, branch) + } + return nil +} + +func (m *MockOps) RebaseContinue() error { + if m.RebaseContinueFn != nil { + return m.RebaseContinueFn() + } + return nil +} + +func (m *MockOps) RebaseAbort() error { + if m.RebaseAbortFn != nil { + return m.RebaseAbortFn() + } + return nil +} + +func (m *MockOps) IsRebaseInProgress() bool { + if m.IsRebaseInProgressFn != nil { + return m.IsRebaseInProgressFn() + } + return false +} + +func (m *MockOps) ConflictedFiles() ([]string, error) { + if m.ConflictedFilesFn != nil { + return m.ConflictedFilesFn() + } + return nil, nil +} + +func (m *MockOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { + if m.FindConflictMarkersFn != nil { + return m.FindConflictMarkersFn(filePath) + } + return nil, nil +} + +func (m *MockOps) IsAncestor(ancestor, descendant string) (bool, error) { + if m.IsAncestorFn != nil { + return m.IsAncestorFn(ancestor, descendant) + } + return false, nil +} + +func (m *MockOps) HeadSHA(ref string) (string, error) { + if m.HeadSHAFn != nil { + return m.HeadSHAFn(ref) + } + return "", nil +} + +func (m *MockOps) MergeBase(a, b string) (string, error) { + if m.MergeBaseFn != nil { + return m.MergeBaseFn(a, b) + } + return "", nil +} + +func (m *MockOps) Log(ref string, maxCount int) ([]CommitInfo, error) { + if m.LogFn != nil { + return m.LogFn(ref, maxCount) + } + return nil, nil +} + +func (m *MockOps) LogRange(base, head string) ([]CommitInfo, error) { + if m.LogRangeFn != nil { + return m.LogRangeFn(base, head) + } + return nil, nil +} + +func (m *MockOps) DiffStatRange(base, head string) (int, int, error) { + if m.DiffStatRangeFn != nil { + return m.DiffStatRangeFn(base, head) + } + return 0, 0, nil +} + +func (m *MockOps) DiffStatFiles(base, head string) ([]FileDiffStat, error) { + if m.DiffStatFilesFn != nil { + return m.DiffStatFilesFn(base, head) + } + return nil, nil +} + +func (m *MockOps) DeleteBranch(name string, force bool) error { + if m.DeleteBranchFn != nil { + return m.DeleteBranchFn(name, force) + } + return nil +} + +func (m *MockOps) DeleteRemoteBranch(remote, branch string) error { + if m.DeleteRemoteBranchFn != nil { + return m.DeleteRemoteBranchFn(remote, branch) + } + return nil +} + +func (m *MockOps) ResetHard(ref string) error { + if m.ResetHardFn != nil { + return m.ResetHardFn(ref) + } + return nil +} + +func (m *MockOps) SetUpstreamTracking(branch, remote string) error { + if m.SetUpstreamTrackingFn != nil { + return m.SetUpstreamTrackingFn(branch, remote) + } + return nil +} + +func (m *MockOps) MergeFF(target string) error { + if m.MergeFFFn != nil { + return m.MergeFFFn(target) + } + return nil +} + +func (m *MockOps) UpdateBranchRef(branch, sha string) error { + if m.UpdateBranchRefFn != nil { + return m.UpdateBranchRefFn(branch, sha) + } + return nil +} + +func (m *MockOps) StageAll() error { + if m.StageAllFn != nil { + return m.StageAllFn() + } + return nil +} + +func (m *MockOps) StageTracked() error { + if m.StageTrackedFn != nil { + return m.StageTrackedFn() + } + return nil +} + +func (m *MockOps) HasStagedChanges() bool { + if m.HasStagedChangesFn != nil { + return m.HasStagedChangesFn() + } + return false +} + +func (m *MockOps) Commit(message string) (string, error) { + if m.CommitFn != nil { + return m.CommitFn(message) + } + return "", nil +} + +func (m *MockOps) ValidateRefName(name string) error { + if m.ValidateRefNameFn != nil { + return m.ValidateRefNameFn(name) + } + return nil +} diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go new file mode 100644 index 0000000..5d5c675 --- /dev/null +++ b/internal/github/client_interface.go @@ -0,0 +1,16 @@ +package github + +// ClientOps defines the interface for GitHub API operations. +// The concrete Client type satisfies this interface. +// Tests can substitute a MockClient. +type ClientOps interface { + FindPRForBranch(branch string) (*PullRequest, error) + FindAnyPRForBranch(branch string) (*PullRequest, error) + FindPRDetailsForBranch(branch string) (*PRDetails, error) + CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) + UpdatePRBase(prID, newBase string) error + DeleteStack() error +} + +// Compile-time check that Client satisfies ClientOps. +var _ ClientOps = (*Client)(nil) diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go new file mode 100644 index 0000000..2389682 --- /dev/null +++ b/internal/github/mock_client.go @@ -0,0 +1,60 @@ +package github + +import "fmt" + +// MockClient is a test double for GitHub API operations. +// Each field is an optional function that, when set, handles the corresponding +// ClientOps method call. When nil, a reasonable default is returned. +type MockClient struct { + FindPRForBranchFn func(string) (*PullRequest, error) + FindAnyPRForBranchFn func(string) (*PullRequest, error) + FindPRDetailsForBranchFn func(string) (*PRDetails, error) + CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) + UpdatePRBaseFn func(string, string) error + DeleteStackFn func() error +} + +// Compile-time check that MockClient satisfies ClientOps. +var _ ClientOps = (*MockClient)(nil) + +func (m *MockClient) FindPRForBranch(branch string) (*PullRequest, error) { + if m.FindPRForBranchFn != nil { + return m.FindPRForBranchFn(branch) + } + return nil, nil +} + +func (m *MockClient) FindAnyPRForBranch(branch string) (*PullRequest, error) { + if m.FindAnyPRForBranchFn != nil { + return m.FindAnyPRForBranchFn(branch) + } + return nil, nil +} + +func (m *MockClient) FindPRDetailsForBranch(branch string) (*PRDetails, error) { + if m.FindPRDetailsForBranchFn != nil { + return m.FindPRDetailsForBranchFn(branch) + } + return nil, nil +} + +func (m *MockClient) CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) { + if m.CreatePRFn != nil { + return m.CreatePRFn(base, head, title, body, draft) + } + return nil, nil +} + +func (m *MockClient) UpdatePRBase(prID, newBase string) error { + if m.UpdatePRBaseFn != nil { + return m.UpdatePRBaseFn(prID, newBase) + } + return nil +} + +func (m *MockClient) DeleteStack() error { + if m.DeleteStackFn != nil { + return m.DeleteStackFn() + } + return fmt.Errorf("deleting a stack on GitHub is not yet supported by the API") +} From 50c8999f78485817c8b961112625a118d0b2fcb9 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 19:13:05 -0400 Subject: [PATCH 35/78] add basic tests --- .github/workflows/test.yml | 24 ++ cmd/add_test.go | 342 +++++++++++++++++ cmd/init_test.go | 251 +++++++++++++ cmd/navigate_test.go | 376 +++++++++++++++++++ cmd/rebase_test.go | 535 +++++++++++++++++++++++++++ cmd/root_test.go | 21 ++ cmd/sync_test.go | 509 +++++++++++++++++++++++++ cmd/view_test.go | 32 ++ internal/branch/name_test.go | 133 +++++++ internal/config/testing.go | 33 ++ internal/stack/stack_test.go | 351 ++++++++++++++++++ internal/tui/stackview/model_test.go | 174 +++++++++ 12 files changed, 2781 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 cmd/add_test.go create mode 100644 cmd/init_test.go create mode 100644 cmd/navigate_test.go create mode 100644 cmd/rebase_test.go create mode 100644 cmd/root_test.go create mode 100644 cmd/sync_test.go create mode 100644 cmd/view_test.go create mode 100644 internal/branch/name_test.go create mode 100644 internal/config/testing.go create mode 100644 internal/stack/stack_test.go create mode 100644 internal/tui/stackview/model_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bc5742e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Tests +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Vet + run: go vet ./... + - name: Test + run: go test -race -count=1 ./... diff --git a/cmd/add_test.go b/cmd/add_test.go new file mode 100644 index 0000000..e7c9667 --- /dev/null +++ b/cmd/add_test.go @@ -0,0 +1,342 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" +) + +// saveStack is a helper to pre-create a stack file for add tests. +func saveStack(t *testing.T, gitDir string, s stack.Stack) { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s}, + } + if err := stack.Save(gitDir, sf); err != nil { + t.Fatalf("saving seed stack: %v", err) + } +} + +func TestAdd_CreatesNewBranch(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + var createdBranch, checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + if createdBranch != "newbranch" { + t.Errorf("CreateBranch got %q, want %q", createdBranch, "newbranch") + } + if checkedOut != "newbranch" { + t.Errorf("CheckoutBranch got %q, want %q", checkedOut, "newbranch") + } + + sf, err := stack.Load(gitDir) + if err != nil { + t.Fatalf("loading stack: %v", err) + } + names := sf.Stacks[0].BranchNames() + if names[len(names)-1] != "newbranch" { + t.Errorf("top branch = %q, want %q", names[len(names)-1], "newbranch") + } +} + +func TestAdd_OnlyAllowedOnTopOfStack(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "top of the stack") { + t.Errorf("expected 'top of the stack' error, got: %s", output) + } +} + +func TestAdd_MutuallyExclusiveFlags(t *testing.T) { + restore := git.SetOps(&git.MockOps{}) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, stageTracked: true, message: "msg"}, []string{"branch"}) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %s", output) + } +} + +func TestAdd_StagingRequiresMessage(t *testing.T) { + restore := git.SetOps(&git.MockOps{}) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true}, []string{"branch"}) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "require -m") { + t.Errorf("expected 'require -m' error, got: %s", output) + } +} + +func TestAdd_EmptyBranchCommitsInPlace(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + createBranchCalled := false + commitCalled := false + stageAllCalled := false + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return nil, nil // no unique commits — branch is empty + }, + StageAllFn: func() error { + stageAllCalled = true + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(msg string) (string, error) { + commitCalled = true + return "abc1234567890", nil + }, + CreateBranchFn: func(name, base string) error { + createBranchCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "Auth middleware"}, nil) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + if !stageAllCalled { + t.Error("expected StageAll to be called") + } + if !commitCalled { + t.Error("expected Commit to be called") + } + if createBranchCalled { + t.Error("CreateBranch should NOT be called for empty branch commit-in-place") + } +} + +func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + createCalled := false + checkoutCalled := false + commitCalled := false + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{SHA: "abc1234567890", Subject: "existing"}}, nil + }, + CreateBranchFn: func(name, base string) error { + createCalled = true + return nil + }, + CheckoutBranchFn: func(name string) error { + checkoutCalled = true + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(msg string) (string, error) { + commitCalled = true + return "def1234567890", nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "API routes"}, nil) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + if !createCalled { + t.Error("expected CreateBranch to be called") + } + if !checkoutCalled { + t.Error("expected CheckoutBranch to be called") + } + if !commitCalled { + t.Error("expected Commit to be called on the new branch") + } +} + +func TestAdd_PrefixAppliedWithSlash(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Prefix: "feat", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat/01"}}, + }) + + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"mybranch"}) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + if createdBranch != "feat/mybranch" { + t.Errorf("created branch = %q, want %q", createdBranch, "feat/mybranch") + } +} + +func TestAdd_NumberedNaming(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Prefix: "feat", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat/01"}}, + }) + + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{SHA: "abc1234567890", Subject: "existing"}}, nil + }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(msg string) (string, error) { + return "def1234567890", nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "next feature"}, nil) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + if createdBranch != "feat/02" { + t.Errorf("created branch = %q, want %q", createdBranch, "feat/02") + } +} + +func TestAdd_FullyMergedStackBlocked(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, 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}}, + }, + }) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "All branches in this stack have been merged") { + t.Errorf("expected merged warning, got: %s", output) + } +} + +func TestAdd_NothingToCommit(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return nil, nil // empty branch + }, + HasStagedChangesFn: func() bool { return false }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "msg"}, nil) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "nothing to commit") { + t.Errorf("expected 'nothing to commit' error, got: %s", output) + } +} diff --git a/cmd/init_test.go b/cmd/init_test.go new file mode 100644 index 0000000..5a40977 --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" +) + +// collectOutput closes the write ends of the test config pipes and returns +// the captured stderr content. Shared across cmd test files. +func collectOutput(cfg *config.Config, outR, errR *os.File) string { + cfg.Out.Close() + cfg.Err.Close() + stderr, _ := io.ReadAll(errR) + outR.Close() + errR.Close() + return string(stderr) +} + +func TestInit_CreatesStackWithCorrectTrunk(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"myBranch"}}) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error in output: %s", output) + } + + sf, err := stack.Load(gitDir) + if err != nil { + t.Fatalf("loading stack: %v", err) + } + if len(sf.Stacks) != 1 { + t.Fatalf("got %d stacks, want 1", len(sf.Stacks)) + } + s := sf.Stacks[0] + if s.Trunk.Branch != "main" { + t.Errorf("trunk = %q, want %q", s.Trunk.Branch, "main") + } + names := s.BranchNames() + if len(names) != 1 || names[0] != "myBranch" { + t.Errorf("branches = %v, want [myBranch]", names) + } +} + +func TestInit_CustomTrunk(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"myBranch"}, base: "develop"}) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + + sf, err := stack.Load(gitDir) + if err != nil { + t.Fatalf("loading stack: %v", err) + } + if got := sf.Stacks[0].Trunk.Branch; got != "develop" { + t.Errorf("trunk = %q, want %q", got, "develop") + } +} + +func TestInit_AdoptExistingBranches(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{ + branches: []string{"b1", "b2", "b3"}, + adopt: true, + }) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + + sf, err := stack.Load(gitDir) + if err != nil { + t.Fatalf("loading stack: %v", err) + } + names := sf.Stacks[0].BranchNames() + want := []string{"b1", "b2", "b3"} + if len(names) != len(want) { + t.Fatalf("branches = %v, want %v", names, want) + } + for i, name := range names { + if name != want[i] { + t.Errorf("branch[%d] = %q, want %q", i, name, want[i]) + } + } +} + +func TestInit_PrefixStoredInStack(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"myBranch"}, prefix: "feat"}) + collectOutput(cfg, outR, errR) + + sf, err := stack.Load(gitDir) + if err != nil { + t.Fatalf("loading stack: %v", err) + } + if got := sf.Stacks[0].Prefix; got != "feat" { + t.Errorf("prefix = %q, want %q", got, "feat") + } +} + +func TestInit_EnablesRerere(t *testing.T) { + gitDir := t.TempDir() + rerereCalled := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + EnableRerereFn: func() error { + rerereCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"b1"}}) + collectOutput(cfg, outR, errR) + + if !rerereCalled { + t.Error("expected EnableRerere to be called") + } +} + +func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { + gitDir := t.TempDir() + + // Pre-create stack file with "feature-1" as a non-trunk branch + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feature-1"}}, + }}, + } + if err := stack.Save(gitDir, sf); err != nil { + t.Fatalf("saving seed stack: %v", err) + } + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "feature-1", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"newBranch"}}) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "already part of a stack") { + t.Errorf("expected 'already part of a stack' error, got: %s", output) + } +} + +func TestInit_AdoptNonexistentBranch(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(string) bool { return false }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "does not exist") { + t.Errorf("expected 'does not exist' error, got: %s", output) + } +} + +func TestInit_MultipleBranches_CreatesAll(t *testing.T) { + gitDir := t.TempDir() + var created []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + if strings.Contains(output, "\u2717") { + t.Fatalf("unexpected error: %s", output) + } + + sf, err := stack.Load(gitDir) + if err != nil { + t.Fatalf("loading stack: %v", err) + } + names := sf.Stacks[0].BranchNames() + if len(names) != 3 { + t.Fatalf("got %d branches, want 3: %v", len(names), names) + } + for i, want := range []string{"b1", "b2", "b3"} { + if names[i] != want { + t.Errorf("branch[%d] = %q, want %q", i, names[i], want) + } + } +} diff --git a/cmd/navigate_test.go b/cmd/navigate_test.go new file mode 100644 index 0000000..0158f8e --- /dev/null +++ b/cmd/navigate_test.go @@ -0,0 +1,376 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "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" + "github.com/stretchr/testify/require" +) + +// readCfgOutput closes cfg writers and reads all captured output. +func readCfgOutput(cfg *config.Config, outR, errR *os.File) string { + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + return string(out) + string(errOut) +} + +func TestNavigate_UpOne(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 "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b2"}, checkedOut) +} + +func TestNavigate_UpN(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 "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetArgs([]string{"2"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b3"}, checkedOut) +} + +func TestNavigate_DownOne(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 := DownCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b2"}, checkedOut) +} + +func TestNavigate_AtTopClamps(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 "b2", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + output := string(out) + string(errOut) + + assert.NoError(t, err) + assert.Empty(t, checkedOut, "should not checkout any branch") + assert.Contains(t, output, "Already at the top") +} + +func TestNavigate_AtBottomClamps(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 "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := DownCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + output := string(out) + string(errOut) + + assert.NoError(t, err) + assert.Empty(t, checkedOut, "should not checkout any branch") + assert.Contains(t, output, "Already at the bottom") +} + +func TestNavigate_FromTrunkGoesUp(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 "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, checkedOut) +} + +func TestNavigate_SkipsMergedBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + {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 "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + output := string(out) + string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []string{"b3"}, checkedOut, "should skip merged b2") + assert.Contains(t, output, "Skipped") +} + +func TestNavigate_Top(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 "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TopCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b3"}, checkedOut) +} + +func TestNavigate_Bottom(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 := BottomCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, checkedOut) +} + +func TestNavigate_BottomWithMergedFirst(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"}, + {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 := BottomCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b2"}, checkedOut, "should skip merged b1") +} + +// writeStackFile is a helper to write a stack file to a temp dir. +func writeStackFile(t *testing.T, dir string, s stack.Stack) { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s}, + } + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644)) +} diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go new file mode 100644 index 0000000..ee59a41 --- /dev/null +++ b/cmd/rebase_test.go @@ -0,0 +1,535 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "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" + "github.com/stretchr/testify/require" +) + +// rebaseCall records arguments passed to RebaseOnto or Rebase. +type rebaseCall struct { + newBase string + oldBase string + branch string +} + +// resetCall records arguments passed to CheckoutBranch + ResetHard. +type resetCall struct { + branch string + sha string +} + +// newRebaseMock creates a MockOps pre-configured for rebase tests. +// It returns stable SHAs based on ref name, tracks checkout, and allows +// callers to override specific function fields after creation. +func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + HeadSHAFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + FetchFn: func(string) error { return nil }, + EnableRerereFn: func() error { return nil }, + IsRebaseInProgressFn: func() bool { return false }, + } +} + +// TestRebase_CascadeRebase verifies that a stack [b1, b2, b3] with all active +// branches triggers the correct cascade: b1 rebased onto trunk, b2 onto b1, +// b3 onto b2. +// +// Per the code: branch at index 0 uses git.Rebase(trunk), subsequent branches +// use git.RebaseOnto(base, originalRefs[base], branch). +func TestRebase_CascadeRebase(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) + + var rebaseCalls []rebaseCall + var checkouts []string + var plainRebaseCalls []string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + return nil + } + mock.RebaseFn = func(base string) error { + plainRebaseCalls = append(plainRebaseCalls, base) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + + // Branch 0 (b1): checkout b1, then Rebase("main") + assert.Contains(t, checkouts, "b1", "b1 should be checked out for plain rebase") + require.Len(t, plainRebaseCalls, 1, "exactly one plain rebase call expected (for b1)") + assert.Equal(t, "main", plainRebaseCalls[0]) + + // Branches 1,2 (b2, b3): RebaseOnto(base, originalRefs[base], branch) + require.Len(t, rebaseCalls, 2, "two RebaseOnto calls expected (for b2, b3)") + assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0]) + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1]) + + assert.Contains(t, output, "rebased locally") +} + +// TestRebase_SquashMergedBranch_UsesOnto verifies that when b1 has a merged PR, +// it is skipped and b2 uses RebaseOnto with trunk as newBase and b1's original +// SHA as oldBase. b3 also uses --onto (propagation). +func TestRebase_SquashMergedBranch_UsesOnto(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b2") + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + + // b2: onto trunk, oldBase = b1's original SHA + // b3: onto b2, oldBase = b2's original SHA (propagation) + require.Len(t, rebaseCalls, 2) + assert.Equal(t, rebaseCall{"main", "sha-b1", "b2"}, rebaseCalls[0], + "b2 should rebase --onto main using b1's original SHA as oldBase") + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1], + "b3 should propagate --onto mode with b2's original SHA as oldBase") +} + +// TestRebase_OntoPropagatesToSubsequentBranches verifies that when multiple +// branches are squash-merged, --onto propagates correctly through the chain. +func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11, Merged: true}}, + {Branch: "b3"}, + {Branch: "b4"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b3") + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + assert.Contains(t, output, "Skipping b2") + + // b1 merged → ontoOldBase = sha-b1 + // b2 merged → ontoOldBase = sha-b2 + // b3: first non-merged ancestor search finds none → newBase = trunk + // RebaseOnto("main", "sha-b2", "b3") + // b4: first non-merged ancestor = b3 → newBase = b3 + // RebaseOnto("b3", "sha-b3", "b4") + require.Len(t, rebaseCalls, 2) + assert.Equal(t, rebaseCall{"main", "sha-b2", "b3"}, rebaseCalls[0], + "b3 should rebase --onto main with b2's SHA as oldBase") + assert.Equal(t, rebaseCall{"b3", "sha-b3", "b4"}, rebaseCalls[1], + "b4 should rebase --onto b3 with b3's original SHA as oldBase") +} + +// TestRebase_ConflictSavesState verifies that when a rebase conflict occurs, +// the state is saved with the conflict branch and remaining branches. +func TestRebase_ConflictSavesState(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) + + mock := newRebaseMock(tmpDir, "b1") + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } // b1 succeeds + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "b2" { + return assert.AnError // conflict on b2 + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "rebase conflict on b2") + assert.Contains(t, output, "--continue") + + // Verify state file was saved + stateData, readErr := os.ReadFile(filepath.Join(tmpDir, "gh-stack-rebase-state")) + require.NoError(t, readErr, "rebase state file should be saved") + + var state rebaseState + require.NoError(t, json.Unmarshal(stateData, &state)) + assert.Equal(t, "b2", state.ConflictBranch) + assert.Equal(t, []string{"b3"}, state.RemainingBranches) + assert.Equal(t, "b1", state.OriginalBranch) + assert.Contains(t, state.OriginalRefs, "b1") + assert.Contains(t, state.OriginalRefs, "b2") + assert.Contains(t, state.OriginalRefs, "b3") +} + +// TestRebase_Continue_NoState verifies that --continue without a state file +// produces a "no rebase in progress" message. +func TestRebase_Continue_NoState(t *testing.T) { + tmpDir := t.TempDir() + + mock := newRebaseMock(tmpDir, "b1") + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err, "should return nil (error printed via cfg.Errorf)") + assert.Contains(t, output, "no rebase in progress") +} + +// TestRebase_Abort_RestoresBranches verifies that --abort restores all branches +// to their original SHAs and removes the state file. +func TestRebase_Abort_RestoresBranches(t *testing.T) { + tmpDir := t.TempDir() + + // Pre-create rebase state + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "b1": "orig-sha-b1", + "b2": "orig-sha-b2", + "b3": "orig-sha-b3", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var resets []resetCall + var checkouts []string + currentBranch := "b2" // simulating we're on the conflict branch + + mock := newRebaseMock(tmpDir, currentBranch) + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + currentBranch = name + return nil + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--abort"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Rebase aborted and branches restored") + + // Verify each branch was reset to its original SHA. + // Map iteration order is non-deterministic, so collect into a map. + resetMap := make(map[string]string) + for _, r := range resets { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "orig-sha-b1", resetMap["b1"]) + assert.Equal(t, "orig-sha-b2", resetMap["b2"]) + assert.Equal(t, "orig-sha-b3", resetMap["b3"]) + + // State file should be removed + _, err = os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(err), "state file should be removed after abort") + + // Should return to original branch + assert.Contains(t, checkouts, "b1", "should checkout original branch at end") +} + +// TestRebase_DownstackOnly verifies that --downstack only rebases branches +// from trunk to the current branch (inclusive). +func TestRebase_DownstackOnly(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) + + var rebasedBranches []string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(base string) error { + // This is called for b1 (index 0) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebasedBranches = append(rebasedBranches, branch) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--downstack"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + // b2 is at index 1, so downstack = [b1, b2] (indices 0..1) + // b1 uses plain Rebase (not tracked here), b2 uses RebaseOnto + assert.Equal(t, []string{"b2"}, rebasedBranches, + "only b2 should use RebaseOnto in downstack mode") + assert.NotContains(t, rebasedBranches, "b3", + "b3 should NOT be rebased in downstack mode") +} + +// TestRebase_UpstackOnly verifies that --upstack only rebases branches +// from the current branch to the top. +func TestRebase_UpstackOnly(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) + + var rebasedBranches []string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebasedBranches = append(rebasedBranches, branch) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--upstack"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + // b2 is at index 1, upstack starts at index 1. + // b2 at absIdx=1 uses RebaseOnto(b1, sha-b1, b2), b3 at absIdx=2 uses RebaseOnto(b2, sha-b2, b3) + assert.Equal(t, []string{"b2", "b3"}, rebasedBranches, + "upstack should rebase b2 and b3") +} + +// TestRebase_SkipsMergedBranches verifies that merged branches are skipped +// with an appropriate message. +func TestRebase_SkipsMergedBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b2") + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + assert.Contains(t, output, "PR #42 merged") + + // Only b2 should be rebased + require.Len(t, rebaseCalls, 1) + assert.Equal(t, "b2", rebaseCalls[0].branch) +} + +// TestRebase_StateRoundTrip verifies that rebase state can be saved and loaded +// back with all fields preserved, including the --onto fields. +func TestRebase_StateRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + + original := &rebaseState{ + CurrentBranchIndex: 2, + ConflictBranch: "feature-b", + RemainingBranches: []string{"feature-c", "feature-d"}, + OriginalBranch: "feature-a", + OriginalRefs: map[string]string{ + "feature-a": "aaa111", + "feature-b": "bbb222", + "feature-c": "ccc333", + "feature-d": "ddd444", + }, + UseOnto: true, + OntoOldBase: "bbb222", + } + + err := saveRebaseState(tmpDir, original) + require.NoError(t, err) + + loaded, err := loadRebaseState(tmpDir) + require.NoError(t, err) + + assert.Equal(t, original.CurrentBranchIndex, loaded.CurrentBranchIndex) + assert.Equal(t, original.ConflictBranch, loaded.ConflictBranch) + assert.Equal(t, original.RemainingBranches, loaded.RemainingBranches) + assert.Equal(t, original.OriginalBranch, loaded.OriginalBranch) + assert.Equal(t, original.OriginalRefs, loaded.OriginalRefs) + assert.Equal(t, original.UseOnto, loaded.UseOnto) + assert.Equal(t, original.OntoOldBase, loaded.OntoOldBase) +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..df3c427 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRootCmd_SubcommandRegistration(t *testing.T) { + root := RootCmd() + expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "merge", "view", "rebase", "up", "down", "top", "bottom", "feedback"} + + registered := make(map[string]bool) + for _, cmd := range root.Commands() { + registered[cmd.Name()] = true + } + + for _, name := range expected { + assert.True(t, registered[name], "expected subcommand %q to be registered", name) + } +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go new file mode 100644 index 0000000..0771323 --- /dev/null +++ b/cmd/sync_test.go @@ -0,0 +1,509 @@ +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" + "github.com/stretchr/testify/require" +) + +// pushCall records arguments passed to Push. +type pushCall struct { + remote string + branches []string + force bool + atomic bool +} + +// newSyncMock creates a MockOps pre-configured for sync tests. By default +// trunk and origin/trunk return the same SHA (no update needed). Override +// HeadSHAFn for specific test scenarios. +func newSyncMock(tmpDir string, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + HeadSHAFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + FetchFn: func(string) error { return nil }, + EnableRerereFn: func() error { return nil }, + IsRebaseInProgressFn: func() bool { return false }, + PushFn: func(string, []string, bool, bool) error { return nil }, + } +} + +// TestSync_TrunkAlreadyUpToDate verifies that when trunk and origin/trunk have +// the same SHA, no rebase occurs and push is normal (not force). +func TestSync_TrunkAlreadyUpToDate(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) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + // Same SHA for trunk and origin/trunk → already up to date + mock.HeadSHAFn = func(ref string) (string, error) { + if ref == "origin/main" { + return "sha-main", nil // same as local trunk + } + return "sha-" + ref, nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.RebaseFn = func(base string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{branch: "rebase-" + base}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "up to date") + assert.Empty(t, rebaseCalls, "no rebase should occur when trunk is up to date") + + // Push should happen without force + require.Len(t, pushCalls, 1) + assert.False(t, pushCalls[0].force, "push should not use force when no rebase occurred") +} + +// TestSync_TrunkFastForward_TriggersRebase verifies that when trunk is behind +// origin/trunk, it fast-forwards and triggers a cascade rebase with force push. +func TestSync_TrunkFastForward_TriggersRebase(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) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + var updateBranchRefCalls []struct{ branch, sha string } + + mock := newSyncMock(tmpDir, "b1") + // Different SHAs for trunk vs origin/trunk + mock.HeadSHAFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + // local is ancestor of remote → can fast-forward + if a == "local-sha" && d == "remote-sha" { + return true, nil + } + return true, nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha}) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(base string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + + // UpdateBranchRef should be called (not on trunk since currentBranch != trunk) + require.Len(t, updateBranchRefCalls, 1, "should fast-forward trunk via UpdateBranchRef") + assert.Equal(t, "main", updateBranchRefCalls[0].branch) + assert.Equal(t, "remote-sha", updateBranchRefCalls[0].sha) + + assert.Contains(t, output, "fast-forwarded") + + // Rebase should have been triggered + assert.NotEmpty(t, rebaseCalls, "rebase should occur after trunk fast-forward") + + // Push should use force-with-lease after rebase + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force, "push should use force-with-lease after rebase") +} + +// TestSync_TrunkFastForward_WhenOnTrunk verifies that when currently on trunk, +// MergeFF is used instead of UpdateBranchRef. +func TestSync_TrunkFastForward_WhenOnTrunk(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var mergeFFCalls []string + var updateBranchRefCalls []string + + mock := newSyncMock(tmpDir, "main") + mock.HeadSHAFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.MergeFFFn = func(target string) error { + mergeFFCalls = append(mergeFFCalls, target) + return nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + updateBranchRefCalls = append(updateBranchRefCalls, branch) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + mock.RebaseOntoFn = func(string, string, string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + assert.Len(t, mergeFFCalls, 1, "should use MergeFF when on trunk") + assert.Equal(t, "origin/main", mergeFFCalls[0]) + assert.Empty(t, updateBranchRefCalls, "should NOT use UpdateBranchRef when on trunk") +} + +// TestSync_TrunkDiverged verifies that when trunk has diverged from origin, +// no rebase occurs and a warning is shown. +func TestSync_TrunkDiverged(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + mock.HeadSHAFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + // Neither is ancestor of the other → diverged + mock.IsAncestorFn = func(a, d string) (bool, error) { + return false, nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "diverged") + assert.Empty(t, rebaseCalls, "no rebase should occur when trunk diverged") + + // Push should happen without force (no rebase occurred) + require.Len(t, pushCalls, 1) + assert.False(t, pushCalls[0].force, "push should not use force when no rebase") +} + +// TestSync_RebaseConflict_RestoresAll verifies that when a rebase conflict +// occurs during sync, all branches are restored to their original state. +func TestSync_RebaseConflict_RestoresAll(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) + + var resets []resetCall + var checkouts []string + currentBranch := "b1" + abortCalled := false + + mock := newSyncMock(tmpDir, "b1") + mock.HeadSHAFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + currentBranch = name + return nil + } + mock.RebaseFn = func(string) error { return nil } // b1 succeeds + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "b2" { + return fmt.Errorf("conflict") + } + return nil + } + mock.RebaseAbortFn = func() error { + abortCalled = true + return nil + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err, "sync returns nil (errors printed via cfg)") + assert.Contains(t, output, "Conflict detected") + assert.Contains(t, output, "gh stack rebase") + + // All branches should be restored + resetMap := make(map[string]string) + for _, r := range resets { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "sha-b1", resetMap["b1"]) + assert.Equal(t, "sha-b2", resetMap["b2"]) + assert.Equal(t, "sha-b3", resetMap["b3"]) + + _ = abortCalled // RebaseAbort is called if IsRebaseInProgress returns true +} + +// TestSync_NoRebaseWhenTrunkDidntMove verifies that when trunk hasn't moved, +// absolutely no rebase calls are made. +func TestSync_NoRebaseWhenTrunkDidntMove(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) + + rebaseCount := 0 + rebaseOntoCount := 0 + + mock := newSyncMock(tmpDir, "b1") + // Same SHA = no trunk movement + mock.HeadSHAFn = func(ref string) (string, error) { + return "same-sha", nil + } + mock.RebaseFn = func(string) error { + rebaseCount++ + return nil + } + mock.RebaseOntoFn = func(string, string, string) error { + rebaseOntoCount++ + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + assert.Equal(t, 0, rebaseCount, "no Rebase calls when trunk didn't move") + assert.Equal(t, 0, rebaseOntoCount, "no RebaseOnto calls when trunk didn't move") +} + +// TestSync_PushForceFlagDependsOnRebase verifies that the force flag on Push +// correlates with whether a rebase actually happened. +func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { + tests := []struct { + name string + trunkMoved bool + expectedForce bool + }{ + {"trunk_moved_force_push", true, true}, + {"trunk_static_normal_push", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + mock.RebaseOntoFn = func(string, string, string) error { return nil } + + if tt.trunkMoved { + mock.HeadSHAFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + } else { + mock.HeadSHAFn = func(ref string) (string, error) { + return "same-sha", nil + } + } + + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + require.Len(t, pushCalls, 1, "exactly one push call expected") + assert.Equal(t, tt.expectedForce, pushCalls[0].force, + "force flag should be %v when trunkMoved=%v", tt.expectedForce, tt.trunkMoved) + }) + } +} diff --git a/cmd/view_test.go b/cmd/view_test.go new file mode 100644 index 0000000..9407068 --- /dev/null +++ b/cmd/view_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +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) + }) + } +} diff --git a/internal/branch/name_test.go b/internal/branch/name_test.go new file mode 100644 index 0000000..95c786b --- /dev/null +++ b/internal/branch/name_test.go @@ -0,0 +1,133 @@ +package branch + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --- Slugify: core cases for branch naming --- + +func TestSlugify(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"spaces to hyphens", "Hello World", "hello-world"}, + {"diacritics stripped", "café résumé", "cafe-resume"}, + {"special chars removed", "feat: add login!", "feat-add-login"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, Slugify(tt.input)) + }) + } + + t.Run("long string truncated at word boundary", func(t *testing.T) { + long := "this is a very long commit message that should definitely be truncated at a word boundary" + result := Slugify(long) + assert.LessOrEqual(t, len(result), 50) + assert.False(t, strings.HasSuffix(result, "-"), "should not end with hyphen") + assert.NotEmpty(t, result) + }) +} + +// --- FollowsNumbering: pattern detection --- + +func TestFollowsNumbering(t *testing.T) { + tests := []struct { + name string + prefix string + branch string + expected bool + }{ + {"matching pattern", "stack", "stack/1", true}, + {"multi-digit", "stack", "stack/42", true}, + {"non-numeric suffix", "stack", "stack/abc", false}, + {"wrong prefix", "other", "stack/1", false}, + {"empty suffix", "stack", "stack/", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, FollowsNumbering(tt.prefix, tt.branch)) + }) + } +} + +// --- NextNumberedName: auto-increment --- + +func TestNextNumberedName(t *testing.T) { + t.Run("empty list starts at 01", func(t *testing.T) { + assert.Equal(t, "prefix/01", NextNumberedName("prefix", nil)) + }) + + t.Run("increments from highest", func(t *testing.T) { + branches := []string{"prefix/01", "prefix/02"} + assert.Equal(t, "prefix/03", NextNumberedName("prefix", branches)) + }) + + t.Run("handles gaps by using max", func(t *testing.T) { + branches := []string{"prefix/01", "prefix/05"} + assert.Equal(t, "prefix/06", NextNumberedName("prefix", branches)) + }) + + t.Run("ignores branches with different prefix", func(t *testing.T) { + branches := []string{"other/10", "prefix/02"} + assert.Equal(t, "prefix/03", NextNumberedName("prefix", branches)) + }) +} + +// --- ResolveBranchName: the full decision tree --- + +func TestResolveBranchName(t *testing.T) { + t.Run("explicit name with prefix uses slash separator", func(t *testing.T) { + name, info := ResolveBranchName("mystack", "", "feature", nil, false) + assert.Equal(t, "mystack/feature", name) + assert.Contains(t, info, "prefixed") + }) + + t.Run("explicit name without prefix uses name as-is", func(t *testing.T) { + name, info := ResolveBranchName("", "", "feature", nil, false) + assert.Equal(t, "feature", name) + assert.Empty(t, info) + }) + + t.Run("message with prefix first branch uses numbered format", func(t *testing.T) { + name, _ := ResolveBranchName("stack", "add login", "", nil, true) + assert.Equal(t, "stack/01", name) + }) + + t.Run("message with prefix last branch follows numbering uses next number", func(t *testing.T) { + existing := []string{"stack/01", "stack/02"} + name, _ := ResolveBranchName("stack", "add login", "", existing, false) + assert.Equal(t, "stack/03", name) + }) + + t.Run("message with prefix last branch not numbered uses date-slug", func(t *testing.T) { + existing := []string{"stack/some-feature"} + name, info := ResolveBranchName("stack", "add login", "", existing, false) + today := time.Now().Format("2006-01-02") + assert.True(t, strings.HasPrefix(name, "stack/"+today), "expected date prefix, got: %s", name) + assert.Contains(t, name, "add-login") + assert.NotEmpty(t, info, "should explain why date+slug was used") + }) + + t.Run("message without prefix uses date-slug", func(t *testing.T) { + name, _ := ResolveBranchName("", "add login", "", nil, false) + today := time.Now().Format("2006-01-02") + assert.True(t, strings.HasPrefix(name, today)) + assert.Contains(t, name, "add-login") + }) + + t.Run("no message no name returns empty", func(t *testing.T) { + name, info := ResolveBranchName("stack", "", "", nil, false) + assert.Empty(t, name) + assert.Empty(t, info) + }) +} diff --git a/internal/config/testing.go b/internal/config/testing.go new file mode 100644 index 0000000..91835b5 --- /dev/null +++ b/internal/config/testing.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "github.com/cli/go-gh/v2/pkg/term" +) + +// NewTestConfig creates a Config suitable for testing with captured output buffers. +// Color functions are no-ops, and the config is non-interactive. +func NewTestConfig() (*Config, *os.File, *os.File) { + outR, outW, _ := os.Pipe() + errR, errW, _ := os.Pipe() + + noop := func(s string) string { return s } + + cfg := &Config{ + Terminal: term.FromEnv(), + Out: outW, + Err: errW, + In: os.Stdin, + ColorSuccess: noop, + ColorError: noop, + ColorWarning: noop, + ColorBold: noop, + ColorBlue: noop, + ColorMagenta: noop, + ColorCyan: noop, + ColorGray: noop, + } + + return cfg, outR, errR +} diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go new file mode 100644 index 0000000..d20f1a3 --- /dev/null +++ b/internal/stack/stack_test.go @@ -0,0 +1,351 @@ +package stack + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeStack(trunk string, branches ...string) Stack { + s := Stack{Trunk: BranchRef{Branch: trunk}} + for _, b := range branches { + s.Branches = append(s.Branches, BranchRef{Branch: b}) + } + return s +} + +func makeMergedBranch(name string, prNum int) BranchRef { + return BranchRef{Branch: name, PullRequest: &PullRequestRef{Number: prNum, Merged: true}} +} + +// --- ActiveBaseBranch: skipping merged ancestors for rebase --- + +func TestActiveBaseBranch(t *testing.T) { + tests := []struct { + name string + stack Stack + branch string + expected string + }{ + { + name: "no merged ancestors returns previous branch", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }, + branch: "b3", + expected: "b2", + }, + { + name: "immediate ancestor merged skips to next non-merged", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b1"}, + makeMergedBranch("b2", 10), + {Branch: "b3"}, + }, + }, + branch: "b3", + expected: "b1", + }, + { + name: "all ancestors merged returns trunk", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + {Branch: "b3"}, + }, + }, + branch: "b3", + expected: "main", + }, + { + name: "first branch always returns trunk", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{{Branch: "b1"}}, + }, + branch: "b1", + expected: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.stack.ActiveBaseBranch(tt.branch)) + }) + } +} + +// --- ActiveBranches / MergedBranches partition --- + +func TestActiveBranches_And_MergedBranches(t *testing.T) { + t.Run("all active", func(t *testing.T) { + s := makeStack("main", "b1", "b2", "b3") + assert.Len(t, s.ActiveBranches(), 3) + assert.Empty(t, s.MergedBranches()) + }) + + t.Run("some merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + {Branch: "b2"}, + makeMergedBranch("b3", 3), + }, + } + active := s.ActiveBranches() + merged := s.MergedBranches() + + assert.Len(t, active, 1) + assert.Equal(t, "b2", active[0].Branch) + assert.Len(t, merged, 2) + assert.Equal(t, "b1", merged[0].Branch) + assert.Equal(t, "b3", merged[1].Branch) + }) + + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.Empty(t, s.ActiveBranches()) + assert.Len(t, s.MergedBranches(), 2) + }) +} + +// --- IsFullyMerged: blocks add on fully-merged stacks --- + +func TestIsFullyMerged(t *testing.T) { + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.True(t, s.IsFullyMerged()) + }) + + t.Run("some active", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + {Branch: "b2"}, + }, + } + assert.False(t, s.IsFullyMerged()) + }) + + t.Run("empty branches is not fully merged", func(t *testing.T) { + s := Stack{Trunk: BranchRef{Branch: "main"}} + assert.False(t, s.IsFullyMerged()) + }) +} + +// --- FirstActiveBranchIndex: navigation --- + +func TestFirstActiveBranchIndex(t *testing.T) { + t.Run("first is active", func(t *testing.T) { + s := makeStack("main", "b1", "b2") + assert.Equal(t, 0, s.FirstActiveBranchIndex()) + }) + + t.Run("first two merged third active", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + {Branch: "b3"}, + }, + } + assert.Equal(t, 2, s.FirstActiveBranchIndex()) + }) + + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.Equal(t, -1, s.FirstActiveBranchIndex()) + }) +} + +// --- Load / Save round-trip persistence --- + +func TestLoad_Save_RoundTrip(t *testing.T) { + t.Run("save and reload preserves all fields", func(t *testing.T) { + dir := t.TempDir() + original := &StackFile{ + Repository: "owner/repo", + Stacks: []Stack{ + { + ID: "s1", + Prefix: "feat", + Trunk: BranchRef{Branch: "main", Head: "abc123"}, + Branches: []BranchRef{ + {Branch: "b1", Head: "def456", Base: "abc123"}, + {Branch: "b2", PullRequest: &PullRequestRef{Number: 42, ID: "PR_id", URL: "https://example.com", Merged: true}}, + }, + }, + }, + } + + require.NoError(t, Save(dir, original)) + + loaded, err := Load(dir) + require.NoError(t, err) + + assert.Equal(t, schemaVersion, loaded.SchemaVersion) + assert.Equal(t, original.Repository, loaded.Repository) + require.Len(t, loaded.Stacks, 1) + + s := loaded.Stacks[0] + assert.Equal(t, "s1", s.ID) + assert.Equal(t, "feat", s.Prefix) + assert.Equal(t, "main", s.Trunk.Branch) + assert.Equal(t, "abc123", s.Trunk.Head) + require.Len(t, s.Branches, 2) + assert.Equal(t, "b1", s.Branches[0].Branch) + assert.Equal(t, "def456", s.Branches[0].Head) + assert.Equal(t, "abc123", s.Branches[0].Base) + require.NotNil(t, s.Branches[1].PullRequest) + assert.Equal(t, 42, s.Branches[1].PullRequest.Number) + assert.True(t, s.Branches[1].PullRequest.Merged) + }) + + t.Run("missing file returns empty stack file", func(t *testing.T) { + dir := t.TempDir() + sf, err := Load(dir) + require.NoError(t, err) + assert.Equal(t, schemaVersion, sf.SchemaVersion) + assert.Empty(t, sf.Stacks) + }) + + t.Run("future schema version returns error", func(t *testing.T) { + dir := t.TempDir() + data, _ := json.Marshal(StackFile{SchemaVersion: 999}) + require.NoError(t, os.WriteFile(filepath.Join(dir, stackFileName), data, 0644)) + + _, err := Load(dir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "999") + }) + + t.Run("corrupt JSON returns error", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, stackFileName), []byte("{not json!"), 0644)) + + _, err := Load(dir) + assert.Error(t, err) + }) +} + +// --- FindStackByPRNumber: used by checkout --- + +func TestFindStackByPRNumber(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{ + { + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b1", PullRequest: &PullRequestRef{Number: 10}}, + {Branch: "b2", PullRequest: &PullRequestRef{Number: 20}}, + }, + }, + { + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b3", PullRequest: &PullRequestRef{Number: 30}}, + }, + }, + }, + } + + t.Run("found", func(t *testing.T) { + s, b := sf.FindStackByPRNumber(20) + require.NotNil(t, s) + require.NotNil(t, b) + assert.Equal(t, "b2", b.Branch) + }) + + t.Run("found in second stack", func(t *testing.T) { + s, b := sf.FindStackByPRNumber(30) + require.NotNil(t, s) + require.NotNil(t, b) + assert.Equal(t, "b3", b.Branch) + }) + + t.Run("not found", func(t *testing.T) { + s, b := sf.FindStackByPRNumber(999) + assert.Nil(t, s) + assert.Nil(t, b) + }) +} + +// --- ValidateNoDuplicateBranch: guards against duplicates --- + +func TestValidateNoDuplicateBranch(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{ + makeStack("main", "b1", "b2"), + }, + } + + t.Run("branch in stack returns error", func(t *testing.T) { + assert.Error(t, sf.ValidateNoDuplicateBranch("b1")) + }) + + t.Run("trunk returns error because Contains checks trunk", func(t *testing.T) { + assert.Error(t, sf.ValidateNoDuplicateBranch("main")) + }) + + t.Run("new branch returns nil", func(t *testing.T) { + assert.NoError(t, sf.ValidateNoDuplicateBranch("new-branch")) + }) +} + +// --- RemoveStackForBranch: used by unstack --- + +func TestRemoveStackForBranch(t *testing.T) { + t.Run("found and removed", func(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{ + makeStack("main", "b1"), + makeStack("main", "b2"), + }, + } + assert.True(t, sf.RemoveStackForBranch("b1")) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "b2", sf.Stacks[0].Branches[0].Branch) + }) + + t.Run("not found", func(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{makeStack("main", "b1")}, + } + assert.False(t, sf.RemoveStackForBranch("nonexistent")) + assert.Len(t, sf.Stacks, 1) + }) +} diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go new file mode 100644 index 0000000..60b8b9b --- /dev/null +++ b/internal/tui/stackview/model_test.go @@ -0,0 +1,174 @@ +package stackview + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" +) + +func makeNodes(branches ...string) []BranchNode { + nodes := make([]BranchNode, len(branches)) + for i, b := range branches { + nodes[i] = BranchNode{ + Ref: stack.BranchRef{Branch: b}, + } + } + return nodes +} + +func keyMsg(k string) tea.KeyMsg { + switch k { + case "up": + return tea.KeyMsg(tea.Key{Type: tea.KeyUp}) + case "down": + return tea.KeyMsg(tea.Key{Type: tea.KeyDown}) + case "enter": + return tea.KeyMsg(tea.Key{Type: tea.KeyEnter}) + case "esc": + return tea.KeyMsg(tea.Key{Type: tea.KeyEscape}) + case "ctrl+c": + return tea.KeyMsg(tea.Key{Type: tea.KeyCtrlC}) + default: + // Single rune key like 'c', 'f', 'q', 'o' + return tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune(k)}) + } +} + +var testTrunk = stack.BranchRef{Branch: "main"} + +func TestNew_CursorAtCurrentBranch(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[1].IsCurrent = true + + m := New(nodes, testTrunk) + + assert.Equal(t, 1, m.cursor) +} + +func TestNew_CursorAtZeroWhenNoCurrent(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + + m := New(nodes, testTrunk) + + assert.Equal(t, 0, m.cursor) +} + +func TestUpdate_KeyboardNavigation(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + m := New(nodes, testTrunk) + assert.Equal(t, 0, m.cursor) + + // Down + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Down again + updated, _ = m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 2, m.cursor) + + // Down at bottom — should clamp + updated, _ = m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 2, m.cursor, "cursor should clamp at bottom") + + // Up + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Up + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 0, m.cursor) + + // Up at top — should clamp + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 0, m.cursor, "cursor should clamp at top") +} + +func TestUpdate_ToggleCommits(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].Commits = []git.CommitInfo{{SHA: "abc", Subject: "test"}} + m := New(nodes, testTrunk) + + assert.False(t, m.nodes[0].CommitsExpanded) + + updated, _ := m.Update(keyMsg("c")) + m = updated.(Model) + assert.True(t, m.nodes[0].CommitsExpanded) + + // Toggle back + updated, _ = m.Update(keyMsg("c")) + m = updated.(Model) + assert.False(t, m.nodes[0].CommitsExpanded) +} + +func TestUpdate_ToggleFiles(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk) + + assert.False(t, m.nodes[0].FilesExpanded) + + updated, _ := m.Update(keyMsg("f")) + m = updated.(Model) + assert.True(t, m.nodes[0].FilesExpanded) + + // Toggle back + updated, _ = m.Update(keyMsg("f")) + m = updated.(Model) + assert.False(t, m.nodes[0].FilesExpanded) +} + +func TestUpdate_Quit(t *testing.T) { + nodes := makeNodes("b1") + m := New(nodes, testTrunk) + + quitKeys := []string{"q", "esc", "ctrl+c"} + for _, k := range quitKeys { + t.Run(k, func(t *testing.T) { + _, cmd := m.Update(keyMsg(k)) + assert.NotNil(t, cmd, "key %q should produce a quit command", k) + }) + } +} + +func TestUpdate_CheckoutOnEnter(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].IsCurrent = true + nodes[1].PR = &ghapi.PRDetails{Number: 42, URL: "https://github.com/pr/42"} + m := New(nodes, testTrunk) + + // Move to b2 (non-current) + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Press enter on non-current node + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + + assert.Equal(t, "b2", m.CheckoutBranch()) + assert.NotNil(t, cmd, "enter on non-current should produce quit command") +} + +func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].IsCurrent = true + m := New(nodes, testTrunk) + assert.Equal(t, 0, m.cursor) + + // Press enter on current node + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + + assert.Equal(t, "", m.CheckoutBranch(), "enter on current branch should not set checkout") + assert.Nil(t, cmd, "enter on current branch should not quit") +} + From 487f148ff209080c08bf445654db0290b4f6c8ea Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 20:20:54 -0400 Subject: [PATCH 36/78] Fix workflow permissions warning --- .github/workflows/release.yml | 2 ++ .github/workflows/test.yml | 5 +++++ go.mod | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d63a842..3b13330 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ name: release + on: push: tags: - "v*" + permissions: contents: write id-token: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc5742e..7c65fe1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,13 @@ name: Tests + on: push: branches: [main] pull_request: + branches: [main] + +permissions: + contents: read jobs: test: diff --git a/go.mod b/go.mod index e158530..2002ae0 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( github.com/cli/shurcooL-graphql v0.0.4 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + golang.org/x/text v0.32.0 ) require ( @@ -41,11 +43,9 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/thlib/go-timezone-local v0.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) From 7d3ceb497540130d238dce8c829c023a8176603a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 20:26:31 -0400 Subject: [PATCH 37/78] typo cleanup --- cmd/navigate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/navigate.go b/cmd/navigate.go index 9db9480..49749c3 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -166,7 +166,7 @@ func runNavigate(cfg *config.Config, delta int) error { } if skipped > 0 { - cfg.Printf("Skipped %d merged %s", skipped, plural(skipped, "branch", "branch(es)")) + cfg.Printf("Skipped %d merged %s", skipped, plural(skipped, "branch", "branches")) } moved := newIdx - idx From deed1ed3e24cb82262c84dbe2f8f007ebcfb451a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 21:05:39 -0400 Subject: [PATCH 38/78] rm old if --- cmd/checkout.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index 8ee61d9..cd473de 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -76,11 +76,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } // Check out the top active branch of the selected stack - if idx := s.FirstActiveBranchIndex(); idx >= 0 { - targetBranch = s.Branches[len(s.Branches)-1].Branch - } else { - targetBranch = s.Branches[len(s.Branches)-1].Branch - } + targetBranch = s.Branches[len(s.Branches)-1].Branch } else { // Resolve target against local stacks s, targetBranch, err = findStackByTarget(sf, opts.target) From 10c3e1b89fda3bf3724b3a821f71c4690dc805ec Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 21:08:05 -0400 Subject: [PATCH 39/78] update description to reflect base as parent head sha --- internal/stack/schema.json | 4 ++-- internal/stack/stack.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/stack/schema.json b/internal/stack/schema.json index 1564936..c59b241 100644 --- a/internal/stack/schema.json +++ b/internal/stack/schema.json @@ -43,7 +43,7 @@ }, "branchRef": { "type": "object", - "description": "A reference to a branch and its associated commit hash. For the trunk, 'head' stores the HEAD commit. For stacked branches, 'base' stores the merge-base commit (the last common commit before divergence from the parent branch).", + "description": "A reference to a branch and its associated commit hash. For the trunk, 'head' stores the HEAD commit. For stacked branches, 'base' stores the parent branch's HEAD SHA at the time of last sync/rebase.", "required": ["branch"], "properties": { "branch": { @@ -56,7 +56,7 @@ }, "base": { "type": "string", - "description": "The merge-base commit SHA — the last commit before this branch diverged from its parent. Used for stacked branches." + "description": "The parent branch's HEAD SHA at the time of last sync/rebase. Used to identify which commits are unique to this branch." }, "pullRequest": { "$ref": "#/$defs/pullRequestRef", diff --git a/internal/stack/stack.go b/internal/stack/stack.go index fde119a..f35c83d 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -23,8 +23,8 @@ type PullRequestRef struct { // BranchRef represents a branch and its associated commit hash. // For the trunk, Head stores the HEAD commit SHA. -// For stacked branches, Base stores the merge-base commit SHA -// (the last common commit before divergence from the parent branch). +// For stacked branches, Base stores the parent branch's HEAD SHA +// at the time of last sync/rebase, used to identify unique commits. type BranchRef struct { Branch string `json:"branch"` Head string `json:"head,omitempty"` From 3a7700e5cbeb367e709c2f492ed17c528c315973 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 12 Mar 2026 22:51:11 -0400 Subject: [PATCH 40/78] consistent merged check --- cmd/utils.go | 2 +- internal/tui/stackview/data.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/utils.go b/cmd/utils.go index a33b3eb..6514bf0 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -73,7 +73,7 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) { for i := range s.Branches { b := &s.Branches[i] - if b.PullRequest != nil && b.PullRequest.Merged { + if b.IsMerged() { continue } diff --git a/internal/tui/stackview/data.go b/internal/tui/stackview/data.go index ae47446..64f8191 100644 --- a/internal/tui/stackview/data.go +++ b/internal/tui/stackview/data.go @@ -48,7 +48,7 @@ func LoadBranchNodes(cfg *config.Config, s *stack.Stack, currentBranch string) [ // For merged branches, use the merge-base (fork point) as the diff // anchor since the base branch has moved past the merge point and // a two-dot diff would show nothing after a squash merge. - isMerged := b.PullRequest != nil && b.PullRequest.Merged + isMerged := b.IsMerged() diffBase := baseBranch if isMerged { if mb, err := git.MergeBase(baseBranch, b.Branch); err == nil { From 1520353f5800b6dbc767c10ed2c5819ed2ccfe42 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 00:40:55 -0400 Subject: [PATCH 41/78] skip interactive prompt if prefix and numbering is set --- cmd/add.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 57f7c34..6ec11b0 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -169,14 +169,21 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { branchName = explicitName } } else { - // No -m, no explicit name — prompt - fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ") - if _, err := fmt.Fscan(cfg.In, &branchName); err != nil { - return fmt.Errorf("could not read branch name: %w", err) - } - if s.Prefix != "" && branchName != "" { - branchName = s.Prefix + "/" + branchName - cfg.Infof("Branch name prefixed: %s", branchName) + // No -m, no explicit name — auto-generate if following numbered + // convention, otherwise prompt for a name. + existingBranches := s.BranchNames() + if s.Prefix != "" && len(existingBranches) > 0 && + branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) { + branchName = branch.NextNumberedName(s.Prefix, existingBranches) + } else { + fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ") + if _, err := fmt.Fscan(cfg.In, &branchName); err != nil { + return fmt.Errorf("could not read branch name: %w", err) + } + if s.Prefix != "" && branchName != "" { + branchName = s.Prefix + "/" + branchName + cfg.Infof("Branch name prefixed: %s", branchName) + } } } From 34eeac434f733cc104b1182239ffd6cffad51669 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 01:48:53 -0400 Subject: [PATCH 42/78] clean up duplicate msgs --- cmd/rebase.go | 28 +++++++++------------------- cmd/sync.go | 2 -- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index 2eacd90..186be09 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -111,8 +111,6 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return nil } - cfg.Printf("Fetching origin ...") - // Enable git rerere so conflict resolutions are remembered. _ = git.EnableRerere() @@ -203,10 +201,8 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } - cfg.Printf("Rebasing %s onto %s (squash-merge detected) ...", br.Branch, newBase) - if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { - cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, newBase) + cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, newBase) remaining := make([]string, 0) for j := i + 1; j < len(branchesToRebase); j++ { @@ -236,12 +232,10 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return fmt.Errorf("rebase conflict on %s", br.Branch) } - cfg.Successf("Rebasing %s onto %s", br.Branch, newBase) + cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) // Keep --onto mode; update old base for the next branch. ontoOldBase = originalRefs[br.Branch] } else { - cfg.Printf("Rebasing %s onto %s ...", br.Branch, base) - var rebaseErr error if absIdx > 0 { // Use --onto to replay only this branch's unique commits. @@ -258,7 +252,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } if rebaseErr != nil { - cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base) + cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, base) remaining := make([]string, 0) for j := i + 1; j < len(branchesToRebase); j++ { @@ -286,7 +280,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return fmt.Errorf("rebase conflict on %s", br.Branch) } - cfg.Successf("Rebasing %s onto %s", br.Branch, base) + cfg.Successf("Rebased %s onto %s", br.Branch, base) } } @@ -384,7 +378,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { } else { baseBranch = s.Trunk.Branch } - cfg.Successf("Rebasing %s onto %s", conflictBranch, baseBranch) + cfg.Successf("Rebased %s onto %s", conflictBranch, baseBranch) for _, branchName := range state.RemainingBranches { idx := s.IndexOf(branchName) @@ -419,8 +413,6 @@ func continueRebase(cfg *config.Config, gitDir string) error { } } - cfg.Printf("Rebasing %s onto %s (squash-merge detected) ...", branchName, newBase) - if err := git.RebaseOnto(newBase, state.OntoOldBase, branchName); err != nil { remainIdx := -1 for ri, rb := range state.RemainingBranches { @@ -437,7 +429,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { cfg.Warningf("failed to save rebase state: %s", err) } - cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, newBase) + cfg.Warningf("Rebasing %s onto %s — conflict", branchName, newBase) printConflictDetails(cfg, newBase) cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run %s", @@ -447,11 +439,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { return fmt.Errorf("rebase conflict on %s", branchName) } - cfg.Successf("Rebasing %s onto %s", branchName, newBase) + cfg.Successf("Rebased %s onto %s (squash-merge detected)", branchName, newBase) state.OntoOldBase = state.OriginalRefs[branchName] } else { - cfg.Printf("Rebasing %s onto %s ...", branchName, base) - var rebaseErr error if idx > 0 { // Use --onto to replay only this branch's unique commits. @@ -479,7 +469,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { cfg.Warningf("failed to save rebase state: %s", err) } - cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, base) + cfg.Warningf("Rebasing %s onto %s — conflict", branchName, base) printConflictDetails(cfg, base) cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run %s", @@ -489,7 +479,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { return fmt.Errorf("rebase conflict on %s", branchName) } - cfg.Successf("Rebasing %s onto %s", branchName, base) + cfg.Successf("Rebased %s onto %s", branchName, base) } } diff --git a/cmd/sync.go b/cmd/sync.go index 8e0c5b1..7b5e471 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -76,8 +76,6 @@ func runSync(cfg *config.Config, _ *syncOptions) error { } // --- Step 1: Fetch --- - cfg.Printf("Fetching origin ...") - // Enable git rerere so conflict resolutions are remembered. _ = git.EnableRerere() From 49e05a00494b0f84a0484ab8e2711964254e2843 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 02:22:19 -0400 Subject: [PATCH 43/78] interactive prompt or autogenerate for pr title --- README.md | 8 ++++++-- cmd/push.go | 52 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7e7d04e..0ff3679 100644 --- a/README.md +++ b/README.md @@ -235,17 +235,21 @@ gh stack push [flags] Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. Uses `--force-with-lease` by default to safely update rebased branches. +When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely. + | Flag | Description | |------|-------------| +| `--auto` | Use auto-generated PR titles without prompting | | `--draft` | Create new PRs as drafts | -| `--dry-run` | Show what would be pushed without actually pushing | +| `--no-prs` | Push branches without creating or updating PRs | **Examples:** ```sh gh stack push +gh stack push --auto gh stack push --draft -gh stack push --dry-run +gh stack push --no-prs ``` ### `gh stack view` diff --git a/cmd/push.go b/cmd/push.go index f4bd75d..4b33d6d 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -2,7 +2,9 @@ package cmd import ( "fmt" + "strings" + "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" @@ -10,8 +12,9 @@ import ( ) type pushOptions struct { - draft bool - dryRun bool + auto bool + draft bool + noPRs bool } func PushCmd(cfg *config.Config) *cobra.Command { @@ -25,8 +28,9 @@ func PushCmd(cfg *config.Config) *cobra.Command { }, } + cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting") cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") - cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Show what would be pushed without pushing") + cmd.Flags().BoolVar(&opts.noPRs, "no-prs", false, "Push branches without creating or updating PRs") return cmd } @@ -79,11 +83,6 @@ func runPush(cfg *config.Config, opts *pushOptions) error { cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches")) } for _, b := range s.ActiveBranches() { - if opts.dryRun { - cfg.Printf("Would push %s", b.Branch) - continue - } - cfg.Printf("Pushing %s...", b.Branch) if err := git.Push("origin", []string{b.Branch}, true, false); err != nil { cfg.Errorf("failed to push %s: %s", b.Branch, err) @@ -91,7 +90,8 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } } - if opts.dryRun { + if opts.noPRs { + cfg.Successf("Pushed %d branches (PR creation skipped)", len(s.ActiveBranches())) return nil } @@ -109,8 +109,17 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } if pr == nil { - // Create new PR - title := b.Branch + // Create new PR — auto-generate title from commits/branch name, + // then prompt interactively unless --auto or non-interactive. + baseBranchForDiff := s.ActiveBaseBranch(b.Branch) + title := defaultPRTitle(baseBranchForDiff, b.Branch) + if !opts.auto && cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) + if err == nil && input != "" { + title = input + } + } body := fmt.Sprintf("Part %d of stack.\n\nBase: `%s`", i+1, baseBranch) newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) @@ -178,3 +187,24 @@ func runPush(cfg *config.Config, opts *pushOptions) error { cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) return nil } + +// defaultPRTitle generates a PR title from the branch's commits. +// If there is exactly one commit, use its subject. Otherwise, humanize the +// branch name (replace hyphens/underscores with spaces). +func defaultPRTitle(base, head string) string { + commits, err := git.LogRange(base, head) + if err == nil && len(commits) == 1 { + return commits[0].Subject + } + return humanize(head) +} + +// humanize replaces hyphens and underscores with spaces. +func humanize(s string) string { + return strings.Map(func(r rune) rune { + if r == '-' || r == '_' { + return ' ' + } + return r + }, s) +} From d5903fe4cd673f0214c60bc68060aca57a81805a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 02:30:52 -0400 Subject: [PATCH 44/78] hyperlinked PRs in output --- cmd/push.go | 8 ++++---- cmd/rebase.go | 4 ++-- cmd/sync.go | 4 ++-- cmd/view.go | 10 ++++------ internal/config/config.go | 15 +++++++++++++++ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/cmd/push.go b/cmd/push.go index 4b33d6d..43a1b64 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -127,7 +127,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) continue } - cfg.Successf("Created PR #%d for %s", newPR.Number, b.Branch) + cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch) s.Branches[i].PullRequest = &stack.PullRequestRef{ Number: newPR.Number, ID: newPR.ID, @@ -137,12 +137,12 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Update base if needed if pr.BaseRefName != baseBranch { if err := client.UpdatePRBase(pr.ID, baseBranch); err != nil { - cfg.Warningf("failed to update PR #%d base: %v", pr.Number, err) + cfg.Warningf("failed to update PR %s base: %v", cfg.PRLink(pr.Number, pr.URL), err) } else { - cfg.Successf("Updated PR #%d base to %s", pr.Number, baseBranch) + cfg.Successf("Updated PR %s base to %s", cfg.PRLink(pr.Number, pr.URL), baseBranch) } } else { - cfg.Printf("PR #%d for %s is up to date", pr.Number, b.Branch) + cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) } if s.Branches[i].PullRequest == nil { s.Branches[i].PullRequest = &stack.PullRequestRef{ diff --git a/cmd/rebase.go b/cmd/rebase.go index 186be09..619832f 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -186,7 +186,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { if br.IsMerged() { ontoOldBase = originalRefs[br.Branch] needsOnto = true - cfg.Successf("Skipping %s (PR #%d merged)", br.Branch, br.PullRequest.Number) + cfg.Successf("Skipping %s (PR %s merged)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) continue } @@ -391,7 +391,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { if br.IsMerged() { state.OntoOldBase = state.OriginalRefs[branchName] state.UseOnto = true - cfg.Successf("Skipping %s (PR #%d merged)", branchName, br.PullRequest.Number) + cfg.Successf("Skipping %s (PR %s merged)", branchName, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) continue } diff --git a/cmd/sync.go b/cmd/sync.go index 7b5e471..fbea522 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -156,7 +156,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if br.IsMerged() { ontoOldBase = originalRefs[br.Branch] needsOnto = true - cfg.Successf("Skipping %s (PR #%d merged)", br.Branch, br.PullRequest.Number) + cfg.Successf("Skipping %s (PR %s merged)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) continue } @@ -274,7 +274,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { continue } if b.PullRequest != nil { - cfg.Successf("PR #%d (%s) — Open", b.PullRequest.Number, b.Branch) + cfg.Successf("PR %s (%s) — Open", cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL), b.Branch) } else { cfg.Warningf("%s has no PR", b.Branch) } diff --git a/cmd/view.go b/cmd/view.go index 2e1aaf2..b51f8ea 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -146,13 +146,11 @@ func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) st if b.PullRequest == nil || b.PullRequest.Number == 0 { return "" } - prNum := fmt.Sprintf("#%d", b.PullRequest.Number) - if owner != "" && repo != "" { - url := fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, b.PullRequest.Number) - prNum = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, prNum) + url := b.PullRequest.URL + if url == "" && owner != "" && repo != "" { + url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, b.PullRequest.Number) } - // Underline to hint that the PR number is a clickable link - prNum = fmt.Sprintf("\033[4m%s\033[24m", prNum) + prNum := cfg.PRLink(b.PullRequest.Number, url) colorFn := cfg.ColorSuccess // green for open if b.PullRequest.Merged { colorFn = cfg.ColorMagenta // purple for merged diff --git a/internal/config/config.go b/internal/config/config.go index 13d0366..e3fc5ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,6 +86,21 @@ func (c *Config) Outf(format string, args ...any) { fmt.Fprintf(c.Out, format, args...) } +// PRLink formats a PR number as a clickable, underlined terminal hyperlink. +// Falls back to plain "#N" when color is disabled. +func (c *Config) PRLink(number int, url string) string { + label := fmt.Sprintf("#%d", number) + if c.Terminal.IsColorEnabled() { + if url != "" { + // OSC 8 hyperlink + label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label) + } + // Underline + label = fmt.Sprintf("\033[4m%s\033[24m", label) + } + return label +} + func (c *Config) IsInteractive() bool { return c.Terminal.IsTerminalOutput() } From f3a797b25036f53d590fdc20b90c710ca4e859d9 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 03:08:50 -0400 Subject: [PATCH 45/78] rebase lowest unmerged branch with tip of trunk --- cmd/rebase.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index 619832f..f432be7 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -120,6 +120,32 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { cfg.Successf("Fetched origin") } + // Fast-forward trunk so the cascade rebase targets the latest upstream. + trunk := s.Trunk.Branch + localSHA, localErr := git.HeadSHA(trunk) + remoteSHA, remoteErr := git.HeadSHA("origin/" + trunk) + + if localErr == nil && remoteErr == nil && localSHA != remoteSHA { + isAncestor, err := git.IsAncestor(localSHA, remoteSHA) + if err != nil { + cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) + } else if !isAncestor { + cfg.Warningf("Trunk %s has diverged from origin — skipping trunk update", trunk) + } else if currentBranch == trunk { + if err := ffMerge(trunk); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + } + } else { + if err := updateBranchRef(trunk, remoteSHA); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + } + } + } + chainParts := []string{s.Trunk.Branch} for _, b := range s.Branches { chainParts = append(chainParts, b.Branch) @@ -373,7 +399,16 @@ func continueRebase(cfg *config.Config, gitDir string) error { } var baseBranch string - if state.CurrentBranchIndex > 0 { + if state.UseOnto { + // The --onto path targets the first non-merged ancestor, or trunk. + baseBranch = s.Trunk.Branch + for j := state.CurrentBranchIndex - 1; j >= 0; j-- { + if !s.Branches[j].IsMerged() { + baseBranch = s.Branches[j].Branch + break + } + } + } else if state.CurrentBranchIndex > 0 { baseBranch = s.Branches[state.CurrentBranchIndex-1].Branch } else { baseBranch = s.Trunk.Branch From 2879cfe9b1e0d564f8b713b39d2fd41239c481e6 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 03:34:05 -0400 Subject: [PATCH 46/78] add links to downstack prs in body --- cmd/push.go | 43 ++++++++++++++++- cmd/push_test.go | 121 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 cmd/push_test.go diff --git a/cmd/push.go b/cmd/push.go index 43a1b64..4165588 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -120,7 +120,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { title = input } } - body := fmt.Sprintf("Part %d of stack.\n\nBase: `%s`", i+1, baseBranch) + body := generatePRBody(s, b.Branch) newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) if createErr != nil { @@ -199,6 +199,47 @@ func defaultPRTitle(base, head string) string { return humanize(head) } +// generatePRBody builds a rich PR description showing the downstack branches, +// the current branch, and a footer with links to the CLI and feedback form. +func generatePRBody(s *stack.Stack, currentBranch string) string { + var lines []string + + // Current branch entry (always first) + lines = append(lines, fmt.Sprintf("- `%s` ← *this PR*", currentBranch)) + + // Walk downstack from just below current to the bottom, skipping merged branches + found := false + for i := len(s.Branches) - 1; i >= 0; i-- { + b := s.Branches[i] + if b.Branch == currentBranch { + found = true + continue + } + if !found { + continue + } + if b.IsMerged() { + continue + } + if b.PullRequest != nil && b.PullRequest.URL != "" { + lines = append(lines, fmt.Sprintf("- `%s` %s", b.Branch, b.PullRequest.URL)) + } else { + lines = append(lines, fmt.Sprintf("- `%s`", b.Branch)) + } + } + + // Trunk entry + lines = append(lines, fmt.Sprintf("- `%s` (base)", s.Trunk.Branch)) + + body := "---\n\n**Stacked Pull Requests**\n" + strings.Join(lines, "\n") + body += fmt.Sprintf( + "\n\nStack created with GitHub Stacks CLIGive Feedback 💬", + feedbackBaseURL, + ) + + return body +} + // humanize replaces hyphens and underscores with spaces. func humanize(s string) string { return strings.Map(func(r rune) rune { diff --git a/cmd/push_test.go b/cmd/push_test.go new file mode 100644 index 0000000..d29c368 --- /dev/null +++ b/cmd/push_test.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "testing" + + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" +) + +func TestGeneratePRBody(t *testing.T) { + tests := []struct { + name string + stack *stack.Stack + currentBranch string + wantContains []string + wantAbsent []string + }{ + { + name: "single branch stack", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feature"}}, + }, + currentBranch: "feature", + wantContains: []string{ + "---", + "**Stacked Pull Requests**", + "- `feature` ← *this PR*", + "- `main` (base)", + "GitHub Stacks CLI", + feedbackBaseURL, + }, + }, + { + name: "multi-branch current is topmost", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "part-1", PullRequest: &stack.PullRequestRef{Number: 1, URL: "https://github.com/org/repo/pull/1"}}, + {Branch: "part-2", PullRequest: &stack.PullRequestRef{Number: 2, URL: "https://github.com/org/repo/pull/2"}}, + {Branch: "part-3"}, + }, + }, + currentBranch: "part-3", + wantContains: []string{ + "- `part-3` ← *this PR*", + "- `part-2` https://github.com/org/repo/pull/2", + "- `part-1` https://github.com/org/repo/pull/1", + "- `main` (base)", + }, + }, + { + name: "current is in the middle excludes upstack", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "part-1", PullRequest: &stack.PullRequestRef{Number: 1, URL: "https://github.com/org/repo/pull/1"}}, + {Branch: "part-2"}, + {Branch: "part-3"}, + }, + }, + currentBranch: "part-2", + wantContains: []string{ + "- `part-2` ← *this PR*", + "- `part-1` https://github.com/org/repo/pull/1", + "- `main` (base)", + }, + wantAbsent: []string{ + "part-3", + }, + }, + { + name: "merged branches are skipped", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "part-1", PullRequest: &stack.PullRequestRef{Number: 1, URL: "https://github.com/org/repo/pull/1", Merged: true}}, + {Branch: "part-2", PullRequest: &stack.PullRequestRef{Number: 2, URL: "https://github.com/org/repo/pull/2"}}, + {Branch: "part-3"}, + }, + }, + currentBranch: "part-3", + wantContains: []string{ + "- `part-3` ← *this PR*", + "- `part-2` https://github.com/org/repo/pull/2", + "- `main` (base)", + }, + wantAbsent: []string{ + "part-1", + }, + }, + { + name: "downstack branch without PR shows branch name only", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "part-1"}, + {Branch: "part-2"}, + }, + }, + currentBranch: "part-2", + wantContains: []string{ + "- `part-2` ← *this PR*", + "- `part-1`", + "- `main` (base)", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generatePRBody(tt.stack, tt.currentBranch) + for _, want := range tt.wantContains { + assert.Contains(t, got, want) + } + for _, absent := range tt.wantAbsent { + assert.NotContains(t, got, absent) + } + }) + } +} From f66e95cac9b67a05a4bf6b9305c891ace3104e20 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 13 Mar 2026 04:40:21 -0400 Subject: [PATCH 47/78] add sticky header to view tui --- cmd/root.go | 1 + cmd/version.go | 7 + cmd/view.go | 2 +- internal/tui/stackview/model.go | 304 +++++++++++++++++++++++++-- internal/tui/stackview/model_test.go | 160 +++++++++++++- internal/tui/stackview/styles.go | 8 + 6 files changed, 452 insertions(+), 30 deletions(-) create mode 100644 cmd/version.go diff --git a/cmd/root.go b/cmd/root.go index 2bc975b..8f97512 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ func RootCmd() *cobra.Command { Use: "stack ", Short: "Manage stacked branches and pull requests", Long: "Create, navigate, and manage stacks of branches and pull requests.", + Version: Version, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..270f51f --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,7 @@ +package cmd + +// Version is the current version of gh-stack. +// It can be overridden at build time via: +// +// go build -ldflags="-X github.com/github/gh-stack/cmd.Version=1.2.3" +var Version = "0.0.1" diff --git a/cmd/view.go b/cmd/view.go index b51f8ea..c3ff0cd 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -176,7 +176,7 @@ func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string) error reversed[len(nodes)-1-i] = n } - model := stackview.New(reversed, s.Trunk) + model := stackview.New(reversed, s.Trunk, Version) p := tea.NewProgram( model, diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index cd6d2b4..dae09c1 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -64,14 +64,45 @@ var keys = keyMap{ ), } +// headerHeight is the total number of lines the header box occupies (top border + 10 art lines + bottom border). +const headerHeight = 12 + +// minHeightForHeader is the minimum terminal height to show the header. +const minHeightForHeader = 25 + +// minWidthForShortcuts is the minimum terminal width to show keyboard shortcuts in the header. +// Below this, the header is shown without the right-side shortcuts column. +const minWidthForShortcuts = 65 + +// minWidthForHeader is the minimum terminal width to show the header at all. +const minWidthForHeader = 50 + +// artLines contains the braille ASCII art displayed in the header. +var artLines = [10]string{ + "⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀", + "⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀", + "⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀", + "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", + "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", + "⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇", + "⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀", + "⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀", + "⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀", +} + +// artDisplayWidth is the visual column width of each art line. +const artDisplayWidth = 20 + // Model is the Bubbletea model for the interactive stack view. type Model struct { - nodes []BranchNode - trunk stack.BranchRef - cursor int // index into nodes (displayed top-down, so 0 = top of stack) - help help.Model - width int - height int + nodes []BranchNode + trunk stack.BranchRef + version string + cursor int // index into nodes (displayed top-down, so 0 = top of stack) + help help.Model + width int + height int // scrollOffset tracks vertical scroll position for tall stacks. scrollOffset int @@ -81,7 +112,7 @@ type Model struct { } // New creates a new stack view model. -func New(nodes []BranchNode, trunk stack.BranchRef) Model { +func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { h := help.New() h.ShowAll = true @@ -95,10 +126,11 @@ func New(nodes []BranchNode, trunk stack.BranchRef) Model { } return Model{ - nodes: nodes, - trunk: trunk, - cursor: cursor, - help: h, + nodes: nodes, + trunk: trunk, + version: version, + cursor: cursor, + help: h, } } @@ -141,6 +173,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.ToggleCommits): if m.cursor >= 0 && m.cursor < len(m.nodes) { m.nodes[m.cursor].CommitsExpanded = !m.nodes[m.cursor].CommitsExpanded + m.clampScroll() m.ensureVisible() } return m, nil @@ -148,6 +181,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.ToggleFiles): if m.cursor >= 0 && m.cursor < len(m.nodes) { m.nodes[m.cursor].FilesExpanded = !m.nodes[m.cursor].FilesExpanded + m.clampScroll() m.ensureVisible() } return m, nil @@ -186,6 +220,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.Button == tea.MouseButtonWheelDown { m.scrollOffset++ + m.clampScroll() return m, nil } } @@ -202,8 +237,17 @@ func openBrowserInBackground(url string) { // handleMouseClick processes a mouse click at the given screen position. func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { - // Map screen Y to content line, accounting for scroll offset - contentLine := screenY + m.scrollOffset + // If header is visible, clicks in the header area are ignored + yOffset := 0 + if m.showHeader() { + if screenY < headerHeight { + return m, nil + } + yOffset = headerHeight + } + + // Map screen Y to content line, accounting for scroll offset and header + contentLine := (screenY - yOffset) + m.scrollOffset // Walk through rendered lines to find which node was clicked. // Account for the merged separator line that may appear between nodes. @@ -235,6 +279,7 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { filesToggleLine := nodeStart + m.filesToggleLineOffset(i) if contentLine == filesToggleLine { m.nodes[i].FilesExpanded = !m.nodes[i].FilesExpanded + m.clampScroll() } } @@ -243,6 +288,7 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { commitToggleLine := nodeStart + m.commitToggleLineOffset(i) if contentLine == commitToggleLine { m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded + m.clampScroll() } } @@ -349,8 +395,8 @@ func (m *Model) ensureVisible() { } endLine := startLine + m.nodeLineCount(m.cursor) - // Available content height (reserve 2 for help bar) - viewHeight := m.height - 2 + // Available content height (reserve space for header or help bar) + viewHeight := m.contentViewHeight() if viewHeight < 1 { viewHeight = 1 } @@ -363,11 +409,71 @@ func (m *Model) ensureVisible() { } } +// showHeader returns true if the terminal is large enough for the header. +func (m Model) showHeader() bool { + return m.height >= minHeightForHeader && m.width >= minWidthForHeader +} + +// showShortcuts returns true if the terminal is wide enough for the shortcuts column in the header. +func (m Model) showShortcuts() bool { + return m.width >= minWidthForShortcuts +} + +// totalContentLines returns the total number of rendered content lines (excluding header). +func (m Model) totalContentLines() int { + lines := 0 + prevWasMerged := false + for i := 0; i < len(m.nodes); i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + lines++ // separator line + } + prevWasMerged = isMerged + lines += m.nodeLineCount(i) + } + lines++ // trunk line + return lines +} + +// contentViewHeight returns the number of lines available for stack content. +func (m Model) contentViewHeight() int { + reserved := 0 + if m.showHeader() { + reserved = headerHeight + } + h := m.height - reserved + if h < 1 { + h = 1 + } + return h +} + +// clampScroll ensures scrollOffset doesn't exceed content bounds. +func (m *Model) clampScroll() { + maxScroll := m.totalContentLines() - m.contentViewHeight() + if maxScroll < 0 { + maxScroll = 0 + } + if m.scrollOffset > maxScroll { + m.scrollOffset = maxScroll + } + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } +} + func (m Model) View() string { if m.width == 0 { return "" } + var out strings.Builder + + showHeader := m.showHeader() + if showHeader { + m.renderHeader(&out) + } + var b strings.Builder // Render nodes in order (index 0 = top of stack, displayed first) @@ -390,14 +496,23 @@ func (m Model) View() string { contentLines := strings.Split(content, "\n") // Apply scrolling - viewHeight := m.height - 2 // reserve for help bar + reservedLines := 0 + if showHeader { + reservedLines = headerHeight + } + viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 } + // Clamp scroll offset so we can't scroll past content + maxScroll := len(contentLines) - viewHeight + if maxScroll < 0 { + maxScroll = 0 + } start := m.scrollOffset - if start > len(contentLines) { - start = len(contentLines) + if start > maxScroll { + start = maxScroll } end := start + viewHeight if end > len(contentLines) { @@ -405,11 +520,158 @@ func (m Model) View() string { } visibleContent := strings.Join(contentLines[start:end], "\n") + out.WriteString(visibleContent) - // Add help bar at the bottom - helpView := m.help.View(keys) + return out.String() +} + +// renderHeader renders the full-width stylized header box with ASCII art, stack info, and keyboard shortcuts. +func (m Model) renderHeader(b *strings.Builder) { + w := m.width + if w < 2 { + return + } + innerWidth := w - 2 // subtract left and right border chars - return visibleContent + "\n" + helpView + // Build info lines (placed to the right of art on specific rows) + mergedCount := 0 + for _, n := range m.nodes { + if n.Ref.IsMerged() { + mergedCount++ + } + } + branchCount := len(m.nodes) + branchInfo := fmt.Sprintf("%d branches", branchCount) + if branchCount == 1 { + branchInfo = "1 branch" + } + if mergedCount > 0 { + branchInfo += fmt.Sprintf(" (%d merged)", mergedCount) + } + + // Branch progress icon: ○ none merged, ◐ some merged, ● all merged + branchIcon := "○" + if mergedCount > 0 && mergedCount < branchCount { + branchIcon = "◐" + } else if branchCount > 0 && mergedCount == branchCount { + branchIcon = "●" + } + + // Info text mapped to art row indices (0-based) + infoByRow := map[int]string{ + 2: headerTitleStyle.Render("GitHub Stacks"), + 3: headerInfoLabelStyle.Render("v" + m.version), + 5: headerInfoStyle.Render("✓") + headerInfoLabelStyle.Render(" Stack initialized"), + 6: headerInfoStyle.Render("◆") + headerInfoLabelStyle.Render(" Base: "+m.trunk.Branch), + 7: headerInfoStyle.Render(branchIcon) + headerInfoLabelStyle.Render(" "+branchInfo), + } + + showShortcuts := m.showShortcuts() + + // Build shortcut lines (rendered content + visual widths) + type shortcutLine struct { + text string + visWidth int + } + var shortcuts []shortcutLine + maxShortcutWidth := 0 + rightColWidth := 0 + + if showShortcuts { + shortcuts = []shortcutLine{ + {headerShortcutKey.Render("↑") + headerShortcutDesc.Render(" up ") + + headerShortcutKey.Render("↓") + headerShortcutDesc.Render(" down"), 0}, + {headerShortcutKey.Render("c") + headerShortcutDesc.Render(" commits"), 0}, + {headerShortcutKey.Render("f") + headerShortcutDesc.Render(" files"), 0}, + {headerShortcutKey.Render("o") + headerShortcutDesc.Render(" open PR"), 0}, + {headerShortcutKey.Render("↵") + headerShortcutDesc.Render(" checkout"), 0}, + {headerShortcutKey.Render("q") + headerShortcutDesc.Render(" quit"), 0}, + } + for i := range shortcuts { + shortcuts[i].visWidth = lipgloss.Width(shortcuts[i].text) + if shortcuts[i].visWidth > maxShortcutWidth { + maxShortcutWidth = shortcuts[i].visWidth + } + } + rightColWidth = maxShortcutWidth + 2 + } + + // Left content base: 1 (margin) + artDisplayWidth + leftContentBase := 1 + artDisplayWidth + + // Vertically center shortcuts within the 10 content rows + scStartRow := 0 + if len(shortcuts) > 0 { + scStartRow = (10 - len(shortcuts)) / 2 + } + + // Top border + b.WriteString(headerBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐")) + b.WriteString("\n") + + // Content rows + gap := " " // gap between art and info text + for i := 0; i < 10; i++ { + art := artLines[i] + + // Build info segment + infoText := "" + infoVisualLen := 0 + if info, ok := infoByRow[i]; ok { + infoText = gap + info + infoVisualLen = 2 + lipgloss.Width(info) + } + + leftUsed := leftContentBase + infoVisualLen + + if showShortcuts { + // Two-column layout: left (art+info) | right (shortcuts) + shortcutCol := innerWidth - rightColWidth + midPad := shortcutCol - leftUsed + if midPad < 0 { + midPad = 0 + } + + scIdx := i - scStartRow + shortcutRendered := "" + scVisWidth := 0 + if scIdx >= 0 && scIdx < len(shortcuts) { + shortcutRendered = shortcuts[scIdx].text + scVisWidth = shortcuts[scIdx].visWidth + } + scTrailingPad := rightColWidth - scVisWidth + if scTrailingPad < 0 { + scTrailingPad = 0 + } + + b.WriteString(headerBorderStyle.Render("│")) + b.WriteString(" ") + b.WriteString(art) + b.WriteString(infoText) + b.WriteString(strings.Repeat(" ", midPad)) + b.WriteString(shortcutRendered) + b.WriteString(strings.Repeat(" ", scTrailingPad)) + b.WriteString(headerBorderStyle.Render("│")) + } else { + // Single-column layout: art + info, padded to fill + trailingPad := innerWidth - leftUsed + if trailingPad < 0 { + trailingPad = 0 + } + + b.WriteString(headerBorderStyle.Render("│")) + b.WriteString(" ") + b.WriteString(art) + b.WriteString(infoText) + b.WriteString(strings.Repeat(" ", trailingPad)) + b.WriteString(headerBorderStyle.Render("│")) + } + b.WriteString("\n") + } + + // Bottom border + b.WriteString(headerBorderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘")) + b.WriteString("\n") } // renderNode renders a single branch node. diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index 60b8b9b..60e44c5 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -1,6 +1,7 @@ package stackview import ( + "fmt" "testing" tea "github.com/charmbracelet/bubbletea" @@ -44,7 +45,7 @@ func TestNew_CursorAtCurrentBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].IsCurrent = true - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") assert.Equal(t, 1, m.cursor) } @@ -52,14 +53,14 @@ func TestNew_CursorAtCurrentBranch(t *testing.T) { func TestNew_CursorAtZeroWhenNoCurrent(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") assert.Equal(t, 0, m.cursor) } func TestUpdate_KeyboardNavigation(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") assert.Equal(t, 0, m.cursor) // Down @@ -96,7 +97,7 @@ func TestUpdate_KeyboardNavigation(t *testing.T) { func TestUpdate_ToggleCommits(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].Commits = []git.CommitInfo{{SHA: "abc", Subject: "test"}} - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") assert.False(t, m.nodes[0].CommitsExpanded) @@ -112,7 +113,7 @@ func TestUpdate_ToggleCommits(t *testing.T) { func TestUpdate_ToggleFiles(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") assert.False(t, m.nodes[0].FilesExpanded) @@ -128,7 +129,7 @@ func TestUpdate_ToggleFiles(t *testing.T) { func TestUpdate_Quit(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") quitKeys := []string{"q", "esc", "ctrl+c"} for _, k := range quitKeys { @@ -143,7 +144,7 @@ func TestUpdate_CheckoutOnEnter(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].IsCurrent = true nodes[1].PR = &ghapi.PRDetails{Number: 42, URL: "https://github.com/pr/42"} - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") // Move to b2 (non-current) updated, _ := m.Update(keyMsg("down")) @@ -161,7 +162,7 @@ func TestUpdate_CheckoutOnEnter(t *testing.T) { func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].IsCurrent = true - m := New(nodes, testTrunk) + m := New(nodes, testTrunk, "0.0.1") assert.Equal(t, 0, m.cursor) // Press enter on current node @@ -172,3 +173,146 @@ func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { assert.Nil(t, cmd, "enter on current branch should not quit") } +func TestView_HeaderShownWhenTallEnough(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + // Simulate a tall and wide terminal + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, "┌") + assert.Contains(t, view, "┘") + assert.Contains(t, view, "GitHub Stacks") + assert.Contains(t, view, "v0.0.1") + assert.Contains(t, view, "Base: main") + assert.Contains(t, view, "2 branches") + assert.Contains(t, view, "↑") + assert.Contains(t, view, "quit") +} + +func TestView_HeaderHiddenWhenShort(t *testing.T) { + nodes := makeNodes("b1") + m := New(nodes, testTrunk, "0.0.1") + + // Simulate a short terminal (below minHeightForHeader) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updated.(Model) + + view := m.View() + // Should NOT contain header box + assert.NotContains(t, view, "┌") + assert.NotContains(t, view, "GitHub Stacks") + // Should NOT contain help bar either (hints are only in header) + assert.NotContains(t, view, "commits") +} + +func TestView_HeaderHiddenWhenNarrow(t *testing.T) { + nodes := makeNodes("b1") + m := New(nodes, testTrunk, "0.0.1") + + // Tall but too narrow for header (below minWidthForHeader) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 35, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.NotContains(t, view, "┌") + assert.NotContains(t, view, "GitHub Stacks") +} + +func TestView_HeaderWithoutShortcutsWhenMediumWidth(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + // Wide enough for header but not for shortcuts (between minWidthForHeader and minWidthForShortcuts) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, "┌", "header should be shown") + assert.Contains(t, view, "GitHub Stacks", "info should be shown") + assert.NotContains(t, view, "checkout", "shortcuts should be hidden at this width") +} + +func TestView_HeaderShowsMergedCount(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Merged: true} + m := New(nodes, testTrunk, "0.0.1") + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, "3 branches (1 merged)") +} + +func TestView_BranchProgressIcon(t *testing.T) { + tests := []struct { + name string + merged []int // indices of merged branches + total int + wantIcon string + }{ + {"none merged", nil, 3, "○"}, + {"some merged", []int{0}, 3, "◐"}, + {"all merged", []int{0, 1, 2}, 3, "●"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + names := make([]string, tt.total) + for i := range names { + names[i] = fmt.Sprintf("b%d", i) + } + nodes := makeNodes(names...) + for _, idx := range tt.merged { + nodes[idx].Ref.PullRequest = &stack.PullRequestRef{Merged: true} + } + m := New(nodes, testTrunk, "0.0.1") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, tt.wantIcon) + }) + } +} + +func TestMouseClick_HeaderAreaIgnored(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + // Click inside the header area (row 5 is inside the 12-line header) + updated, _ = m.Update(tea.MouseMsg{ + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + X: 10, + Y: 5, + }) + result := updated.(Model) + assert.Equal(t, 0, result.cursor, "clicking in header should not change cursor") +} + +func TestScrollClamp_CannotScrollPastContent(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + // Tall terminal with plenty of room for content + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + m = updated.(Model) + + // Scroll down many times — should not scroll past content + for i := 0; i < 50; i++ { + updated, _ = m.Update(tea.MouseMsg{ + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelDown, + }) + m = updated.(Model) + } + + // scrollOffset should be clamped (content fits in view, so offset stays 0) + view := m.View() + assert.Contains(t, view, "b1", "content should still be visible after excessive scrolling") +} diff --git a/internal/tui/stackview/styles.go b/internal/tui/stackview/styles.go index 10c2c32..a93ce04 100644 --- a/internal/tui/stackview/styles.go +++ b/internal/tui/stackview/styles.go @@ -41,6 +41,14 @@ var ( // Dim text (separators, secondary labels) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + // Header styles + headerBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray box-drawing chars + headerTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) // white bold + headerInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan + headerInfoLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + headerShortcutKey = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white + headerShortcutDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + // Expand/collapse toggle expandedIcon = "▾" collapsedIcon = "▸" From a27a4f5ad9012aaa31e03977c85254a78fbdb4f5 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 18 Mar 2026 01:02:59 -0400 Subject: [PATCH 48/78] rename flag to --skip-prs --- README.md | 4 ++-- cmd/push.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0ff3679..1e10c90 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ When creating new PRs, you will be prompted to enter a title for each one. Press |------|-------------| | `--auto` | Use auto-generated PR titles without prompting | | `--draft` | Create new PRs as drafts | -| `--no-prs` | Push branches without creating or updating PRs | +| `--skip-prs` | Push branches without creating or updating PRs | **Examples:** @@ -249,7 +249,7 @@ When creating new PRs, you will be prompted to enter a title for each one. Press gh stack push gh stack push --auto gh stack push --draft -gh stack push --no-prs +gh stack push --skip-prs ``` ### `gh stack view` diff --git a/cmd/push.go b/cmd/push.go index 4165588..3ae8508 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -14,7 +14,7 @@ import ( type pushOptions struct { auto bool draft bool - noPRs bool + skipPRs bool } func PushCmd(cfg *config.Config) *cobra.Command { @@ -30,7 +30,7 @@ func PushCmd(cfg *config.Config) *cobra.Command { cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting") cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") - cmd.Flags().BoolVar(&opts.noPRs, "no-prs", false, "Push branches without creating or updating PRs") + cmd.Flags().BoolVar(&opts.skipPRs, "skip-prs", false, "Push branches without creating or updating PRs") return cmd } @@ -90,7 +90,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } } - if opts.noPRs { + if opts.skipPRs { cfg.Successf("Pushed %d branches (PR creation skipped)", len(s.ActiveBranches())) return nil } From 9d2a7cf8dda7061adca1fc42eb629873a2794988 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 19 Mar 2026 12:39:34 -0400 Subject: [PATCH 49/78] use multiline commit msgs for pr body --- cmd/push.go | 68 ++++++++++-------------- cmd/push_test.go | 102 +++++------------------------------- internal/git/git.go | 1 + internal/git/gitops.go | 29 ++++++++-- internal/git/gitops_test.go | 61 +++++++++++++++++++++ 5 files changed, 125 insertions(+), 136 deletions(-) create mode 100644 internal/git/gitops_test.go diff --git a/cmd/push.go b/cmd/push.go index 3ae8508..8ba7ca2 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -112,7 +112,8 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Create new PR — auto-generate title from commits/branch name, // then prompt interactively unless --auto or non-interactive. baseBranchForDiff := s.ActiveBaseBranch(b.Branch) - title := defaultPRTitle(baseBranchForDiff, b.Branch) + title, commitBody := defaultPRTitleBody(baseBranchForDiff, b.Branch) + originalTitle := title if !opts.auto && cfg.IsInteractive() { p := prompter.New(cfg.In, cfg.Out, cfg.Err) input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) @@ -120,7 +121,15 @@ func runPush(cfg *config.Config, opts *pushOptions) error { title = input } } - body := generatePRBody(s, b.Branch) + + // If the user changed the title and the commit had a multi-line + // message, put the full commit message in the PR body so no + // content is lost. + prBody := commitBody + if title != originalTitle && commitBody != "" { + prBody = originalTitle + "\n\n" + commitBody + } + body := generatePRBody(prBody) newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) if createErr != nil { @@ -188,56 +197,33 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } -// defaultPRTitle generates a PR title from the branch's commits. -// If there is exactly one commit, use its subject. Otherwise, humanize the -// branch name (replace hyphens/underscores with spaces). -func defaultPRTitle(base, head string) string { +// defaultPRTitleBody generates a PR title and body from the branch's commits. +// If there is exactly one commit, use its subject as the title and its body +// (if any) as the PR body. Otherwise, humanize the branch name for the title. +func defaultPRTitleBody(base, head string) (string, string) { commits, err := git.LogRange(base, head) if err == nil && len(commits) == 1 { - return commits[0].Subject + return commits[0].Subject, strings.TrimSpace(commits[0].Body) } - return humanize(head) + return humanize(head), "" } -// generatePRBody builds a rich PR description showing the downstack branches, -// the current branch, and a footer with links to the CLI and feedback form. -func generatePRBody(s *stack.Stack, currentBranch string) string { - var lines []string - - // Current branch entry (always first) - lines = append(lines, fmt.Sprintf("- `%s` ← *this PR*", currentBranch)) +// generatePRBody builds a PR description from the commit body (if any) +// and a footer linking to the CLI and feedback form. +func generatePRBody(commitBody string) string { + var parts []string - // Walk downstack from just below current to the bottom, skipping merged branches - found := false - for i := len(s.Branches) - 1; i >= 0; i-- { - b := s.Branches[i] - if b.Branch == currentBranch { - found = true - continue - } - if !found { - continue - } - if b.IsMerged() { - continue - } - if b.PullRequest != nil && b.PullRequest.URL != "" { - lines = append(lines, fmt.Sprintf("- `%s` %s", b.Branch, b.PullRequest.URL)) - } else { - lines = append(lines, fmt.Sprintf("- `%s`", b.Branch)) - } + if commitBody != "" { + parts = append(parts, commitBody) } - // Trunk entry - lines = append(lines, fmt.Sprintf("- `%s` (base)", s.Trunk.Branch)) - - body := "---\n\n**Stacked Pull Requests**\n" + strings.Join(lines, "\n") - body += fmt.Sprintf( - "\n\nStack created with GitHub Stacks CLIGive Feedback 💬", + footer := fmt.Sprintf( + "Stack created with GitHub Stacks CLIGive Feedback 💬", feedbackBaseURL, ) + parts = append(parts, footer) - return body + return strings.Join(parts, "\n\n---\n\n") } // humanize replaces hyphens and underscores with spaces. diff --git a/cmd/push_test.go b/cmd/push_test.go index d29c368..a82fdae 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -3,119 +3,41 @@ package cmd import ( "testing" - "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" ) func TestGeneratePRBody(t *testing.T) { tests := []struct { - name string - stack *stack.Stack - currentBranch string - wantContains []string - wantAbsent []string + name string + commitBody string + wantContains []string }{ { - name: "single branch stack", - stack: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "feature"}}, - }, - currentBranch: "feature", + name: "empty commit body", + commitBody: "", wantContains: []string{ - "---", - "**Stacked Pull Requests**", - "- `feature` ← *this PR*", - "- `main` (base)", "GitHub Stacks CLI", feedbackBaseURL, + "", }, }, { - name: "multi-branch current is topmost", - stack: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "part-1", PullRequest: &stack.PullRequestRef{Number: 1, URL: "https://github.com/org/repo/pull/1"}}, - {Branch: "part-2", PullRequest: &stack.PullRequestRef{Number: 2, URL: "https://github.com/org/repo/pull/2"}}, - {Branch: "part-3"}, - }, - }, - currentBranch: "part-3", + name: "with commit body", + commitBody: "This is a detailed description\nof the change.", wantContains: []string{ - "- `part-3` ← *this PR*", - "- `part-2` https://github.com/org/repo/pull/2", - "- `part-1` https://github.com/org/repo/pull/1", - "- `main` (base)", - }, - }, - { - name: "current is in the middle excludes upstack", - stack: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "part-1", PullRequest: &stack.PullRequestRef{Number: 1, URL: "https://github.com/org/repo/pull/1"}}, - {Branch: "part-2"}, - {Branch: "part-3"}, - }, - }, - currentBranch: "part-2", - wantContains: []string{ - "- `part-2` ← *this PR*", - "- `part-1` https://github.com/org/repo/pull/1", - "- `main` (base)", - }, - wantAbsent: []string{ - "part-3", - }, - }, - { - name: "merged branches are skipped", - stack: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "part-1", PullRequest: &stack.PullRequestRef{Number: 1, URL: "https://github.com/org/repo/pull/1", Merged: true}}, - {Branch: "part-2", PullRequest: &stack.PullRequestRef{Number: 2, URL: "https://github.com/org/repo/pull/2"}}, - {Branch: "part-3"}, - }, - }, - currentBranch: "part-3", - wantContains: []string{ - "- `part-3` ← *this PR*", - "- `part-2` https://github.com/org/repo/pull/2", - "- `main` (base)", - }, - wantAbsent: []string{ - "part-1", - }, - }, - { - name: "downstack branch without PR shows branch name only", - stack: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "part-1"}, - {Branch: "part-2"}, - }, - }, - currentBranch: "part-2", - wantContains: []string{ - "- `part-2` ← *this PR*", - "- `part-1`", - "- `main` (base)", + "This is a detailed description\nof the change.", + "GitHub Stacks CLI", + "", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := generatePRBody(tt.stack, tt.currentBranch) + got := generatePRBody(tt.commitBody) for _, want := range tt.wantContains { assert.Contains(t, got, want) } - for _, absent := range tt.wantAbsent { - assert.NotContains(t, got, absent) - } }) } } diff --git a/internal/git/git.go b/internal/git/git.go index 2c98008..f81a291 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -17,6 +17,7 @@ var client = &cligit.Client{} type CommitInfo struct { SHA string Subject string + Body string Time time.Time } diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 97d78ae..7e0bc85 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -264,7 +264,7 @@ func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { } func (d *defaultOps) LogRange(base, head string) ([]CommitInfo, error) { - format := "%H\t%s\t%at" + format := "%H%x01%B%x01%at%x00" rangeSpec := base + ".." + head output, err := run("log", rangeSpec, "--format="+format) if err != nil { @@ -275,21 +275,40 @@ func (d *defaultOps) LogRange(base, head string) ([]CommitInfo, error) { } var commits []CommitInfo - for _, line := range strings.Split(output, "\n") { - parts := strings.SplitN(line, "\t", 3) + for _, record := range strings.Split(output, "\x00") { + record = strings.TrimSpace(record) + if record == "" { + continue + } + parts := strings.SplitN(record, "\x01", 3) if len(parts) < 3 { continue } - ts, _ := strconv.ParseInt(parts[2], 10, 64) + ts, _ := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64) + subject, body := splitCommitMessage(parts[1]) commits = append(commits, CommitInfo{ SHA: parts[0], - Subject: parts[1], + Subject: subject, + Body: body, Time: time.Unix(ts, 0), }) } return commits, nil } +// splitCommitMessage splits a full commit message into subject (first line) +// and body (remaining lines with leading/trailing blank lines trimmed). +func splitCommitMessage(msg string) (subject, body string) { + msg = strings.TrimSpace(msg) + if i := strings.IndexByte(msg, '\n'); i >= 0 { + subject = msg[:i] + body = strings.TrimSpace(msg[i+1:]) + } else { + subject = msg + } + return +} + func (d *defaultOps) DiffStatRange(base, head string) (additions, deletions int, err error) { output, err := run("diff", "--numstat", base+".."+head) if err != nil { diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go new file mode 100644 index 0000000..9d0e7d4 --- /dev/null +++ b/internal/git/gitops_test.go @@ -0,0 +1,61 @@ +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitCommitMessage(t *testing.T) { + tests := []struct { + name string + msg string + wantSubject string + wantBody string + }{ + { + name: "single line", + msg: "Fix the bug", + wantSubject: "Fix the bug", + wantBody: "", + }, + { + name: "subject and body with blank separator", + msg: "Fix the bug\n\nMore details about the fix.", + wantSubject: "Fix the bug", + wantBody: "More details about the fix.", + }, + { + name: "multi-line without blank separator", + msg: "Fix the bug\nMore details\nEven more", + wantSubject: "Fix the bug", + wantBody: "More details\nEven more", + }, + { + name: "body with leading and trailing blank lines trimmed", + msg: "Fix the bug\n\n\nSome body text\n\n", + wantSubject: "Fix the bug", + wantBody: "Some body text", + }, + { + name: "whitespace-only body", + msg: "Fix the bug\n\n \n\n", + wantSubject: "Fix the bug", + wantBody: "", + }, + { + name: "leading whitespace on message trimmed", + msg: "\n Fix the bug\n\nBody here", + wantSubject: "Fix the bug", + wantBody: "Body here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subject, body := splitCommitMessage(tt.msg) + assert.Equal(t, tt.wantSubject, subject) + assert.Equal(t, tt.wantBody, body) + }) + } +} From 627832dcd0cbe929088b45f06b3c6985d9b78e02 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 19 Mar 2026 13:22:58 -0400 Subject: [PATCH 50/78] rm placeholders --- README.md | 6 ------ cmd/placeholder.go | 35 ----------------------------------- cmd/root.go | 5 ----- 3 files changed, 46 deletions(-) delete mode 100644 cmd/placeholder.go diff --git a/README.md b/README.md index 1e10c90..236514a 100644 --- a/README.md +++ b/README.md @@ -358,12 +358,6 @@ gh stack feedback gh stack feedback "Support for reordering branches" ``` -### Placeholder commands - -The following commands are planned but not yet implemented. Running them prints a notice and suggests using `gh stack feedback` to share your interest. - -`remove` · `modify` · `reorder` · `move` · `fold` · `squash` · `rename` · `split` - ## Typical workflow ```sh diff --git a/cmd/placeholder.go b/cmd/placeholder.go deleted file mode 100644 index 5715640..0000000 --- a/cmd/placeholder.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "github.com/github/gh-stack/internal/config" - "github.com/spf13/cobra" -) - -type placeholderDef struct { - Name string - Short string -} - -var placeholderCommands = []placeholderDef{ - {"remove", "Remove a branch from a stack"}, - {"modify", "Modify a branch in a stack"}, - {"reorder", "Reorder branches in a stack"}, - {"move", "Move a branch between stacks"}, - {"fold", "Fold a branch into the branch below it"}, - {"squash", "Squash commits in a branch"}, - {"rename", "Rename a branch in a stack"}, - {"split", "Split a branch into two branches"}, -} - -func PlaceholderCmd(def placeholderDef, cfg *config.Config) *cobra.Command { - return &cobra.Command{ - Use: def.Name, - Short: def.Short, - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - cfg.Warningf("`gh stack %s` is not yet supported.", def.Name) - cfg.Infof("Run `gh stack feedback` to share your thoughts on this feature.") - return nil - }, - } -} diff --git a/cmd/root.go b/cmd/root.go index 8f97512..ae175a8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,11 +47,6 @@ func RootCmd() *cobra.Command { // Feedback root.AddCommand(FeedbackCmd(cfg)) - // Placeholders for upcoming features - for _, ph := range placeholderCommands { - root.AddCommand(PlaceholderCmd(ph, cfg)) - } - return root } From e874e2637439dcc5e3648b0e8a5310359a6ee816 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sat, 21 Mar 2026 20:43:14 -0700 Subject: [PATCH 51/78] push all branches atomically and resolve remotes --- cmd/add.go | 2 +- cmd/init.go | 2 +- cmd/push.go | 81 +++++++++++++++++++++++---------------- cmd/rebase.go | 82 ++++++++++++++-------------------------- cmd/rebase_test.go | 2 +- cmd/sync.go | 69 ++++++++++++++------------------- cmd/sync_test.go | 20 +++++----- cmd/utils.go | 56 +++++++++++++++++++++++++++ internal/git/git.go | 43 +++++++++++++++++++-- internal/git/gitops.go | 55 ++++++++++++++++++++++++++- internal/git/mock_ops.go | 33 ++++++++++++++-- 11 files changed, 296 insertions(+), 149 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 6ec11b0..f8410c1 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -213,7 +213,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return nil } - base, _ := git.HeadSHA(currentBranch) + base, _ := git.RevParse(currentBranch) s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base}) // Stage and commit on the NEW branch if -m is provided diff --git a/cmd/init.go b/cmd/init.go index 587b0e0..92cab0e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -213,7 +213,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } // Build stack - trunkSHA, _ := git.HeadSHA(trunk) + trunkSHA, _ := git.RevParse(trunk) branchRefs := make([]stack.BranchRef, len(branches)) for i, b := range branches { parent := trunk diff --git a/cmd/push.go b/cmd/push.go index 8ba7ca2..d4badd8 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "strings" @@ -12,8 +13,8 @@ import ( ) type pushOptions struct { - auto bool - draft bool + auto bool + draft bool skipPRs bool } @@ -54,22 +55,18 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } - s, err := resolveStack(sf, currentBranch, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil - } - if s == nil { + // Find the stack for the current branch without switching branches. + // Push should never change the user's checked-out branch. + stacks := sf.FindAllStacksForBranch(currentBranch) + if len(stacks) == 0 { cfg.Errorf("current branch %q is not part of a stack", currentBranch) return nil } - - // Re-read current branch in case disambiguation caused a checkout - currentBranch, err = git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) + if len(stacks) > 1 { + cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch) return nil } + s := stacks[0] client, err := cfg.GitHubClient() if err != nil { @@ -77,17 +74,21 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return nil } - // Push all branches + // Push all active branches atomically + remote, err := pickRemote(cfg, currentBranch) + if err != nil { + cfg.Errorf("%s", err) + return nil + } merged := s.MergedBranches() if len(merged) > 0 { cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches")) } - for _, b := range s.ActiveBranches() { - cfg.Printf("Pushing %s...", b.Branch) - if err := git.Push("origin", []string{b.Branch}, true, false); err != nil { - cfg.Errorf("failed to push %s: %s", b.Branch, err) - return nil - } + activeBranches := activeBranchNames(s) + cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote) + if err := git.Push(remote, activeBranches, true, true); err != nil { + cfg.Errorf("failed to push: %s", err) + return nil } if opts.skipPRs { @@ -174,18 +175,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { fmt.Fprintf(cfg.Err, " grouped into a Stack.\n") // Update base commit hashes and sync PR state - for i := range s.Branches { - if s.Branches[i].IsMerged() { - continue - } - parent := s.ActiveBaseBranch(s.Branches[i].Branch) - if base, err := git.HeadSHA(parent); err == nil { - s.Branches[i].Base = base - } - if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { - s.Branches[i].Head = head - } - } + updateBaseSHAs(s) syncStackPRs(cfg, s) if err := stack.Save(gitDir, sf); err != nil { @@ -235,3 +225,30 @@ func humanize(s string) string { return r }, s) } + +// pickRemote determines which remote to push to. It delegates to +// git.ResolveRemote for config-based resolution and remote listing. +// If multiple remotes exist with no configured default, the user is +// prompted to select one interactively. +func pickRemote(cfg *config.Config, branch string) (string, error) { + remote, err := git.ResolveRemote(branch) + if err == nil { + return remote, nil + } + + var multi *git.ErrMultipleRemotes + if !errors.As(err, &multi) { + return "", err + } + + if !cfg.IsInteractive() { + return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal") + } + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes) + if promptErr != nil { + return "", fmt.Errorf("remote selection: %w", promptErr) + } + return multi.Remotes[selected], nil +} diff --git a/cmd/rebase.go b/cmd/rebase.go index f432be7..c3f2511 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -114,25 +114,35 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { // Enable git rerere so conflict resolutions are remembered. _ = git.EnableRerere() - if err := git.Fetch("origin"); err != nil { - cfg.Warningf("Failed to fetch origin: %v", err) + // Resolve remote for fetch and trunk comparison + remote, err := pickRemote(cfg, currentBranch) + if err != nil { + cfg.Errorf("%s", err) + return nil + } + + if err := git.Fetch(remote); err != nil { + cfg.Warningf("Failed to fetch %s: %v", remote, err) } else { - cfg.Successf("Fetched origin") + cfg.Successf("Fetched %s", remote) } // Fast-forward trunk so the cascade rebase targets the latest upstream. trunk := s.Trunk.Branch - localSHA, localErr := git.HeadSHA(trunk) - remoteSHA, remoteErr := git.HeadSHA("origin/" + trunk) + localSHA, remoteSHA := "", "" + trunkRefs, trunkErr := git.RevParseMulti([]string{trunk, remote + "/" + trunk}) + if trunkErr == nil { + localSHA, remoteSHA = trunkRefs[0], trunkRefs[1] + } - if localErr == nil && remoteErr == nil && localSHA != remoteSHA { + if trunkErr == nil && localSHA != remoteSHA { isAncestor, err := git.IsAncestor(localSHA, remoteSHA) if err != nil { cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) } else if !isAncestor { - cfg.Warningf("Trunk %s has diverged from origin — skipping trunk update", trunk) + cfg.Warningf("Trunk %s has diverged from %s — skipping trunk update", trunk, remote) } else if currentBranch == trunk { - if err := ffMerge(trunk); err != nil { + if err := git.MergeFF(remote + "/" + trunk); err != nil { cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) } else { cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) @@ -184,14 +194,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { // Sync PR state before rebase so we can detect merged PRs. syncStackPRs(cfg, s) - originalRefs := make(map[string]string) - for _, b := range s.Branches { - sha, err := git.HeadSHA(b.Branch) - if err != nil { - cfg.Errorf("failed to resolve HEAD SHA for %s: %s", b.Branch, err) - return nil - } - originalRefs[b.Branch] = sha + branchNames := make([]string, len(s.Branches)) + for i, b := range s.Branches { + branchNames[i] = b.Branch + } + originalRefs, err := git.RevParseMap(branchNames) + if err != nil { + cfg.Errorf("failed to resolve branch SHAs: %s", err) + return nil } // Track --onto rebase state for squash-merged branches. @@ -312,25 +322,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { _ = git.CheckoutBranch(currentBranch) - for i := range s.Branches { - // Skip merged branches when updating base SHAs. - if s.Branches[i].IsMerged() { - continue - } - // Find the first non-merged ancestor, or trunk. - parent := s.Trunk.Branch - for j := i - 1; j >= 0; j-- { - if !s.Branches[j].IsMerged() { - parent = s.Branches[j].Branch - break - } - } - base, _ := git.HeadSHA(parent) - s.Branches[i].Base = base - if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { - s.Branches[i].Head = head - } - } + updateBaseSHAs(s) syncStackPRs(cfg, s) @@ -521,25 +513,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { clearRebaseState(gitDir) _ = git.CheckoutBranch(state.OriginalBranch) - for i := range s.Branches { - // Skip merged branches when updating base SHAs. - if s.Branches[i].IsMerged() { - continue - } - // Find the first non-merged ancestor, or trunk. - parent := s.Trunk.Branch - for j := i - 1; j >= 0; j-- { - if !s.Branches[j].IsMerged() { - parent = s.Branches[j].Branch - break - } - } - base, _ := git.HeadSHA(parent) - s.Branches[i].Base = base - if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { - s.Branches[i].Head = head - } - } + updateBaseSHAs(s) syncStackPRs(cfg, s) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index ee59a41..9a9c0a3 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -34,7 +34,7 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, CurrentBranchFn: func() (string, error) { return currentBranch, nil }, - HeadSHAFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, FetchFn: func(string) error { return nil }, EnableRerereFn: func() error { return nil }, diff --git a/cmd/sync.go b/cmd/sync.go index fbea522..730ac8d 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -22,10 +22,10 @@ func SyncCmd(cfg *config.Config) *cobra.Command { This command performs a safe, non-interactive synchronization: - 1. Fetches the latest changes from origin + 1. Fetches the latest changes from the remote 2. Fast-forwards the trunk branch to match the remote 3. Cascade-rebases stack branches onto their updated parents - 4. Pushes all branches (using --force-with-lease) + 4. Pushes all branches atomically (using --force-with-lease --atomic) 5. Syncs PR state from GitHub If a rebase conflict is detected, all branches are restored to their @@ -75,24 +75,34 @@ func runSync(cfg *config.Config, _ *syncOptions) error { return nil } + // Resolve remote once for fetch and push + remote, err := pickRemote(cfg, currentBranch) + if err != nil { + cfg.Errorf("%s", err) + return nil + } + // --- Step 1: Fetch --- // Enable git rerere so conflict resolutions are remembered. _ = git.EnableRerere() - if err := git.Fetch("origin"); err != nil { - cfg.Warningf("Failed to fetch origin: %v", err) + if err := git.Fetch(remote); err != nil { + cfg.Warningf("Failed to fetch %s: %v", remote, err) } else { - cfg.Successf("Fetched latest changes") + cfg.Successf("Fetched latest changes from %s", remote) } // --- Step 2: Fast-forward trunk --- trunk := s.Trunk.Branch trunkUpdated := false - localSHA, localErr := git.HeadSHA(trunk) - remoteSHA, remoteErr := git.HeadSHA("origin/" + trunk) + localSHA, remoteSHA := "", "" + trunkRefs, trunkErr := git.RevParseMulti([]string{trunk, remote + "/" + trunk}) + if trunkErr == nil { + localSHA, remoteSHA = trunkRefs[0], trunkRefs[1] + } - if localErr != nil || remoteErr != nil { + if trunkErr != nil { cfg.Warningf("Could not compare trunk %s with remote — skipping trunk update", trunk) } else if localSHA == remoteSHA { cfg.Successf("Trunk %s is already up to date", trunk) @@ -101,13 +111,12 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if err != nil { cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) } else if !isAncestor { - cfg.Warningf("Trunk %s has diverged from origin — skipping trunk update", trunk) + cfg.Warningf("Trunk %s has diverged from %s — skipping trunk update", trunk, remote) cfg.Printf(" Local and remote %s have diverged. Resolve manually.", trunk) } else { // Fast-forward the trunk branch if currentBranch == trunk { - // Can't update ref of checked-out branch; merge instead - if err := ffMerge(trunk); err != nil { + if err := git.MergeFF(remote + "/" + trunk); err != nil { cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) } else { cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) @@ -134,11 +143,11 @@ func runSync(cfg *config.Config, _ *syncOptions) error { syncStackPRs(cfg, s) // Save original refs so we can restore on conflict - originalRefs := make(map[string]string) - for _, b := range s.Branches { - sha, _ := git.HeadSHA(b.Branch) - originalRefs[b.Branch] = sha + branchNames := make([]string, len(s.Branches)) + for i, b := range s.Branches { + branchNames[i] = b.Branch } + originalRefs, _ := git.RevParseMap(branchNames) needsOnto := false var ontoOldBase string @@ -231,12 +240,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // --- Step 4: Push --- cfg.Printf("") - var branches []string - for _, b := range s.Branches { - if !b.IsMerged() { - branches = append(branches, b.Branch) - } - } + branches := activeBranchNames(s) if mergedCount := len(s.MergedBranches()); mergedCount > 0 { cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches")) @@ -248,8 +252,8 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // After rebase, force-with-lease is required (history rewritten). // Without rebase, try a normal push first. force := rebased - cfg.Printf("Pushing branches ...") - if err := git.Push("origin", branches, force, false); err != nil { + cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote) + if err := git.Push(remote, branches, force, true); err != nil { if !force { cfg.Warningf("Push failed — branches may need force push after rebase") cfg.Printf(" Run %s to push with --force-with-lease.", @@ -293,19 +297,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { } // --- Step 6: Update base SHAs and save --- - for i := range s.Branches { - // Skip merged branches when updating base SHAs. - if s.Branches[i].IsMerged() { - continue - } - parent := s.ActiveBaseBranch(s.Branches[i].Branch) - if base, err := git.HeadSHA(parent); err == nil { - s.Branches[i].Base = base - } - if head, err := git.HeadSHA(s.Branches[i].Branch); err == nil { - s.Branches[i].Head = head - } - } + updateBaseSHAs(s) if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) @@ -317,11 +309,6 @@ func runSync(cfg *config.Config, _ *syncOptions) error { return nil } -// ffMerge fast-forwards the currently checked-out branch to match origin. -func ffMerge(branch string) error { - return git.MergeFF("origin/" + branch) -} - // updateBranchRef updates a branch ref to point to a new SHA (for branches not checked out). func updateBranchRef(branch, sha string) error { return git.UpdateBranchRef(branch, sha) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 0771323..d74ece6 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -22,12 +22,12 @@ type pushCall struct { // newSyncMock creates a MockOps pre-configured for sync tests. By default // trunk and origin/trunk return the same SHA (no update needed). Override -// HeadSHAFn for specific test scenarios. +// RevParseFn for specific test scenarios. func newSyncMock(tmpDir string, currentBranch string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, CurrentBranchFn: func() (string, error) { return currentBranch, nil }, - HeadSHAFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, FetchFn: func(string) error { return nil }, EnableRerereFn: func() error { return nil }, @@ -55,7 +55,7 @@ func TestSync_TrunkAlreadyUpToDate(t *testing.T) { mock := newSyncMock(tmpDir, "b1") // Same SHA for trunk and origin/trunk → already up to date - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { if ref == "origin/main" { return "sha-main", nil // same as local trunk } @@ -116,7 +116,7 @@ func TestSync_TrunkFastForward_TriggersRebase(t *testing.T) { mock := newSyncMock(tmpDir, "b1") // Different SHAs for trunk vs origin/trunk - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { return "local-sha", nil } @@ -197,7 +197,7 @@ func TestSync_TrunkFastForward_WhenOnTrunk(t *testing.T) { var updateBranchRefCalls []string mock := newSyncMock(tmpDir, "main") - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { return "local-sha", nil } @@ -256,7 +256,7 @@ func TestSync_TrunkDiverged(t *testing.T) { var pushCalls []pushCall mock := newSyncMock(tmpDir, "b1") - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { return "local-sha", nil } @@ -321,7 +321,7 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { abortCalled := false mock := newSyncMock(tmpDir, "b1") - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { return "local-sha", nil } @@ -403,7 +403,7 @@ func TestSync_NoRebaseWhenTrunkDidntMove(t *testing.T) { mock := newSyncMock(tmpDir, "b1") // Same SHA = no trunk movement - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { return "same-sha", nil } mock.RebaseFn = func(string) error { @@ -464,7 +464,7 @@ func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { mock.RebaseOntoFn = func(string, string, string) error { return nil } if tt.trunkMoved { - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { return "local-sha", nil } @@ -478,7 +478,7 @@ func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { } mock.UpdateBranchRefFn = func(string, string) error { return nil } } else { - mock.HeadSHAFn = func(ref string) (string, error) { + mock.RevParseFn = func(ref string) (string, error) { return "same-sha", nil } } diff --git a/cmd/utils.go b/cmd/utils.go index 6514bf0..8844100 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -90,3 +90,59 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) { } } } + +// updateBaseSHAs refreshes the Base and Head SHAs for all active branches +// in a stack. Call this after any operation that may have moved branch refs +// (rebase, push, etc.). +func updateBaseSHAs(s *stack.Stack) { + // Collect all refs we need to resolve, then batch into one git call. + var refs []string + type refPair struct { + index int + parent string + branch string + } + var pairs []refPair + seen := make(map[string]bool) + for i := range s.Branches { + if s.Branches[i].IsMerged() { + continue + } + parent := s.ActiveBaseBranch(s.Branches[i].Branch) + branch := s.Branches[i].Branch + pairs = append(pairs, refPair{i, parent, branch}) + if !seen[parent] { + refs = append(refs, parent) + seen[parent] = true + } + if !seen[branch] { + refs = append(refs, branch) + seen[branch] = true + } + } + if len(refs) == 0 { + return + } + shaMap, err := git.RevParseMap(refs) + if err != nil { + return + } + for _, p := range pairs { + if base, ok := shaMap[p.parent]; ok { + s.Branches[p.index].Base = base + } + if head, ok := shaMap[p.branch]; ok { + s.Branches[p.index].Head = head + } + } +} + +// activeBranchNames returns the branch names for all non-merged branches in a stack. +func activeBranchNames(s *stack.Stack) []string { + active := s.ActiveBranches() + names := make([]string, len(active)) + for i, b := range active { + names[i] = b.Branch + } + return names +} diff --git a/internal/git/git.go b/internal/git/git.go index f81a291..3840103 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "fmt" "os" "os/exec" "strings" @@ -13,6 +14,16 @@ import ( // client is a shared git client used by all package-level functions. var client = &cligit.Client{} +// ErrMultipleRemotes is returned by ResolveRemote when multiple remotes +// are configured and none is designated as the push target. +type ErrMultipleRemotes struct { + Remotes []string +} + +func (e *ErrMultipleRemotes) Error() string { + return fmt.Sprintf("multiple remotes configured: %s", strings.Join(e.Remotes, ", ")) +} + // CommitInfo holds metadata about a single commit. type CommitInfo struct { SHA string @@ -118,6 +129,13 @@ func Push(remote string, branches []string, force, atomic bool) error { return ops.Push(remote, branches, force, atomic) } +// ResolveRemote determines the remote for pushing a branch. Checks git +// config in priority order, falls back to listing remotes. Returns +// *ErrMultipleRemotes if multiple remotes exist with no configured default. +func ResolveRemote(branch string) (string, error) { + return ops.ResolveRemote(branch) +} + // Rebase rebases the current branch onto the given base. // If rerere resolves all conflicts automatically, the rebase continues // without user intervention. @@ -191,9 +209,28 @@ func IsAncestor(ancestor, descendant string) (bool, error) { return ops.IsAncestor(ancestor, descendant) } -// HeadSHA returns the full SHA of the given ref. -func HeadSHA(ref string) (string, error) { - return ops.HeadSHA(ref) +// RevParse resolves a ref to its full SHA via git rev-parse. +func RevParse(ref string) (string, error) { + return ops.RevParse(ref) +} + +// RevParseMulti resolves multiple refs to their full SHAs in a single +// git rev-parse invocation. Returns SHAs in the same order as the input refs. +func RevParseMulti(refs []string) ([]string, error) { + return ops.RevParseMulti(refs) +} + +// RevParseMap resolves multiple refs and returns a ref→SHA map. +func RevParseMap(refs []string) (map[string]string, error) { + shas, err := ops.RevParseMulti(refs) + if err != nil { + return nil, err + } + m := make(map[string]string, len(refs)) + for i, ref := range refs { + m[ref] = shas[i] + } + return m, nil } // MergeBase returns the best common ancestor commit between two refs. diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 7e0bc85..97bf5a4 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -3,6 +3,7 @@ package git import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -23,6 +24,7 @@ type Ops interface { DefaultBranch() (string, error) CreateBranch(name, base string) error Push(remote string, branches []string, force, atomic bool) error + ResolveRemote(branch string) (string, error) Rebase(base string) error EnableRerere() error RebaseOnto(newBase, oldBase, branch string) error @@ -32,7 +34,8 @@ type Ops interface { ConflictedFiles() ([]string, error) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) IsAncestor(ancestor, descendant string) (bool, error) - HeadSHA(ref string) (string, error) + RevParse(ref string) (string, error) + RevParseMulti(refs []string) ([]string, error) MergeBase(a, b string) (string, error) Log(ref string, maxCount int) ([]CommitInfo, error) LogRange(base, head string) ([]CommitInfo, error) @@ -122,6 +125,38 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) return runSilent(args...) } +// ResolveRemote determines the remote for pushing a branch. It checks git +// config keys in priority order (branch..pushRemote, remote.pushDefault, +// branch..remote), then falls back to listing all remotes. If exactly +// one remote exists it is returned. If multiple exist, ErrMultipleRemotes is +// returned with the list attached. If none exist, a plain error is returned. +func (d *defaultOps) ResolveRemote(branch string) (string, error) { + candidates := []string{ + "branch." + branch + ".pushRemote", + "remote.pushDefault", + "branch." + branch + ".remote", + } + for _, key := range candidates { + out, err := run("config", "--get", key) + if err == nil && out != "" { + return out, nil + } + } + + out, err := run("remote") + if err != nil { + return "", fmt.Errorf("could not list remotes: %w", err) + } + remotes := strings.Fields(strings.TrimSpace(out)) + if len(remotes) == 1 { + return remotes[0], nil + } + if len(remotes) > 1 { + return "", &ErrMultipleRemotes{Remotes: remotes} + } + return "", fmt.Errorf("no remotes configured") +} + func (d *defaultOps) Rebase(base string) error { err := runSilent("rebase", base) if err == nil { @@ -229,10 +264,26 @@ func (d *defaultOps) IsAncestor(ancestor, descendant string) (bool, error) { return false, err } -func (d *defaultOps) HeadSHA(ref string) (string, error) { +func (d *defaultOps) RevParse(ref string) (string, error) { return run("rev-parse", ref) } +func (d *defaultOps) RevParseMulti(refs []string) ([]string, error) { + if len(refs) == 0 { + return nil, nil + } + args := append([]string{"rev-parse"}, refs...) + out, err := run(args...) + if err != nil { + return nil, err + } + shas := strings.Split(out, "\n") + if len(shas) != len(refs) { + return nil, fmt.Errorf("rev-parse returned %d SHAs for %d refs", len(shas), len(refs)) + } + return shas, nil +} + func (d *defaultOps) MergeBase(a, b string) (string, error) { return run("merge-base", a, b) } diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 906bc3d..79f4bd4 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -12,6 +12,7 @@ type MockOps struct { DefaultBranchFn func() (string, error) CreateBranchFn func(string, string) error PushFn func(string, []string, bool, bool) error + ResolveRemoteFn func(string) (string, error) RebaseFn func(string) error EnableRerereFn func() error RebaseOntoFn func(string, string, string) error @@ -21,7 +22,8 @@ type MockOps struct { ConflictedFilesFn func() ([]string, error) FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) IsAncestorFn func(string, string) (bool, error) - HeadSHAFn func(string) (string, error) + RevParseFn func(string) (string, error) + RevParseMultiFn func([]string) ([]string, error) MergeBaseFn func(string, string) (string, error) LogFn func(string, int) ([]CommitInfo, error) LogRangeFn func(string, string) ([]CommitInfo, error) @@ -98,6 +100,13 @@ func (m *MockOps) Push(remote string, branches []string, force, atomic bool) err return nil } +func (m *MockOps) ResolveRemote(branch string) (string, error) { + if m.ResolveRemoteFn != nil { + return m.ResolveRemoteFn(branch) + } + return "origin", nil +} + func (m *MockOps) Rebase(base string) error { if m.RebaseFn != nil { return m.RebaseFn(base) @@ -161,13 +170,29 @@ func (m *MockOps) IsAncestor(ancestor, descendant string) (bool, error) { return false, nil } -func (m *MockOps) HeadSHA(ref string) (string, error) { - if m.HeadSHAFn != nil { - return m.HeadSHAFn(ref) +func (m *MockOps) RevParse(ref string) (string, error) { + if m.RevParseFn != nil { + return m.RevParseFn(ref) } return "", nil } +func (m *MockOps) RevParseMulti(refs []string) ([]string, error) { + if m.RevParseMultiFn != nil { + return m.RevParseMultiFn(refs) + } + // Default: delegate to RevParse for each ref. + shas := make([]string, len(refs)) + for i, ref := range refs { + sha, err := m.RevParse(ref) + if err != nil { + return nil, err + } + shas[i] = sha + } + return shas, nil +} + func (m *MockOps) MergeBase(a, b string) (string, error) { if m.MergeBaseFn != nil { return m.MergeBaseFn(a, b) From 22fa202522702fedc1e72b6cba997693cbad0356 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 08:52:03 -0700 Subject: [PATCH 52/78] shared loadStack helper for all cmds --- cmd/add.go | 36 ++++----------------------- cmd/navigate.go | 55 +++++------------------------------------ cmd/rebase.go | 32 +++--------------------- cmd/sync.go | 36 ++++----------------------- cmd/unstack.go | 30 ++++------------------ cmd/utils.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/view.go | 37 ++++----------------------- 7 files changed, 96 insertions(+), 196 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index f8410c1..bef2778 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -55,40 +55,14 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return nil } - gitDir, err := git.GitDir() + result, err := loadStack(cfg, "") if err != nil { - cfg.Errorf("not a git repository") - return nil - } - - sf, err := stack.Load(gitDir) - if err != nil { - cfg.Errorf("failed to load stack state: %s", err) - return nil - } - - currentBranch, err := git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) - return nil - } - - s, err := resolveStack(sf, currentBranch, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil - } - if s == nil { - cfg.Errorf("current branch %q is not part of a stack; run 'gh stack init' first", currentBranch) - return nil - } - - // Re-read current branch in case disambiguation caused a checkout - currentBranch, err = git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) return nil } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch if s.IsFullyMerged() { cfg.Warningf("All branches in this stack have been merged") diff --git a/cmd/navigate.go b/cmd/navigate.go index 49749c3..eece488 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -5,7 +5,6 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" - "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -60,10 +59,12 @@ func BottomCmd(cfg *config.Config) *cobra.Command { } func runNavigate(cfg *config.Config, delta int) error { - s, currentBranch, err := loadCurrentStack(cfg) + result, err := loadStack(cfg, "") if err != nil { return nil } + s := result.Stack + currentBranch := result.CurrentBranch idx := s.IndexOf(currentBranch) if idx < 0 { @@ -181,11 +182,12 @@ func runNavigate(cfg *config.Config, delta int) error { } func runNavigateToEnd(cfg *config.Config, top bool) error { - s, currentBranch, err := loadCurrentStack(cfg) + result, err := loadStack(cfg, "") if err != nil { - cfg.Errorf("failed to load current stack: %s", err) return nil } + s := result.Stack + currentBranch := result.CurrentBranch if len(s.Branches) == 0 { cfg.Errorf("stack has no branches") @@ -226,51 +228,6 @@ func runNavigateToEnd(cfg *config.Config, top bool) error { return nil } -func loadCurrentStack(cfg *config.Config) (*stack.Stack, string, error) { - gitDir, err := git.GitDir() - if err != nil { - errMsg := "not a git repository" - cfg.Errorf("%s", errMsg) - return nil, "", fmt.Errorf("%s", errMsg) - } - - sf, err := stack.Load(gitDir) - if err != nil { - errMsg := fmt.Sprintf("failed to load stack state: %s", err) - cfg.Errorf("%s", errMsg) - return nil, "", fmt.Errorf("%s", errMsg) - } - - currentBranch, err := git.CurrentBranch() - if err != nil { - errMsg := fmt.Sprintf("failed to get current branch: %s", err) - cfg.Errorf("%s", errMsg) - return nil, "", fmt.Errorf("%s", errMsg) - } - - s, err := resolveStack(sf, currentBranch, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil, "", err - } - if s == nil { - errMsg := fmt.Sprintf("current branch %q is not part of a stack", currentBranch) - cfg.Errorf("current branch %q is not part of a stack", currentBranch) - cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) - return nil, "", fmt.Errorf("%s", errMsg) - } - - // Re-read current branch in case disambiguation caused a checkout - currentBranch, err = git.CurrentBranch() - if err != nil { - errMsg := fmt.Sprintf("failed to get current branch: %s", err) - cfg.Errorf("%s", errMsg) - return nil, "", fmt.Errorf("%s", errMsg) - } - - return s, currentBranch, nil -} - func plural(n int, singular, pluralForm string) string { if n == 1 { return singular diff --git a/cmd/rebase.go b/cmd/rebase.go index c3f2511..faee83a 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -79,37 +79,13 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return abortRebase(cfg, gitDir) } - sf, err := stack.Load(gitDir) - if err != nil { - cfg.Errorf("failed to load stack state: %s", err) - return nil - } - - currentBranch := opts.branch - if currentBranch == "" { - currentBranch, err = git.CurrentBranch() - if err != nil { - cfg.Errorf("unable to determine current branch: %s", err) - return nil - } - } - - s, err := resolveStack(sf, currentBranch, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil - } - if s == nil { - cfg.Errorf("no stack found for branch %s", currentBranch) - return nil - } - - // Re-read current branch in case disambiguation caused a checkout - currentBranch, err = git.CurrentBranch() + result, err := loadStack(cfg, opts.branch) if err != nil { - cfg.Errorf("failed to get current branch: %s", err) return nil } + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch // Enable git rerere so conflict resolutions are remembered. _ = git.EnableRerere() diff --git a/cmd/sync.go b/cmd/sync.go index 730ac8d..beeef6c 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -40,40 +40,14 @@ conflicts interactively.`, } func runSync(cfg *config.Config, _ *syncOptions) error { - gitDir, err := git.GitDir() + result, err := loadStack(cfg, "") if err != nil { - cfg.Errorf("not a git repository") - return nil - } - - sf, err := stack.Load(gitDir) - if err != nil { - cfg.Errorf("failed to load stack state: %s", err) - return nil - } - - currentBranch, err := git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) - return nil - } - - s, err := resolveStack(sf, currentBranch, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil - } - if s == nil { - cfg.Errorf("current branch %q is not part of a stack", currentBranch) - return nil - } - - // Re-read current branch in case disambiguation caused a checkout - currentBranch, err = git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) return nil } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch // Resolve remote once for fetch and push remote, err := pickRemote(cfg, currentBranch) diff --git a/cmd/unstack.go b/cmd/unstack.go index 0dee3d3..2f6acc2 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -2,7 +2,6 @@ package cmd import ( "github.com/github/gh-stack/internal/config" - "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -34,35 +33,16 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { } func runUnstack(cfg *config.Config, opts *unstackOptions) error { - gitDir, err := git.GitDir() + result, err := loadStack(cfg, opts.target) if err != nil { - cfg.Errorf("not a git repository") return nil } - - sf, err := stack.Load(gitDir) - if err != nil { - cfg.Errorf("failed to load stack state: %s", err) - return nil - } - + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack target := opts.target if target == "" { - target, err = git.CurrentBranch() - if err != nil { - cfg.Errorf("unable to determine current branch: %s", err) - return nil - } - } - - s, err := resolveStack(sf, target, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil - } - if s == nil { - cfg.Errorf("branch %q is not part of a stack", target) - return nil + target = result.CurrentBranch } cfg.Printf("Stack branches: %v", s.BranchNames()) diff --git a/cmd/utils.go b/cmd/utils.go index 8844100..56562e6 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -9,6 +9,72 @@ import ( "github.com/github/gh-stack/internal/stack" ) +// loadStackResult holds everything returned by loadStack. +type loadStackResult struct { + GitDir string + StackFile *stack.StackFile + Stack *stack.Stack + CurrentBranch string +} + +// loadStack is the standard way to obtain a Stack for the current (or given) +// branch. It resolves the git directory, loads the stack file, determines the +// branch, calls resolveStack (which may prompt for disambiguation), checks for +// a nil stack, and re-reads the current branch (in case disambiguation caused +// a checkout). Errors are printed via cfg and returned. +func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil, fmt.Errorf("not a git repository") + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil, fmt.Errorf("failed to load stack state: %w", err) + } + + branchFromArg := branch != "" + if branch == "" { + branch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil, fmt.Errorf("failed to get current branch: %w", err) + } + } + + s, err := resolveStack(sf, branch, cfg) + if err != nil { + cfg.Errorf("%s", err) + return nil, err + } + if s == nil { + if branchFromArg { + cfg.Errorf("branch %q is not part of a stack", branch) + } else { + cfg.Errorf("current branch %q is not part of a stack", branch) + } + cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", + cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) + return nil, fmt.Errorf("branch %q is not part of a stack", branch) + } + + // Re-read current branch in case disambiguation caused a checkout. + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil, fmt.Errorf("failed to get current branch: %w", err) + } + + return &loadStackResult{ + GitDir: gitDir, + StackFile: sf, + Stack: s, + CurrentBranch: currentBranch, + }, nil +} + // resolveStack finds the stack for the given branch, handling ambiguity when // a branch (typically a trunk) belongs to multiple stacks. If exactly one // stack matches, it is returned directly. If multiple stacks match, the user diff --git a/cmd/view.go b/cmd/view.go index c3ff0cd..a5697e9 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -40,41 +40,14 @@ func ViewCmd(cfg *config.Config) *cobra.Command { } func runView(cfg *config.Config, opts *viewOptions) error { - gitDir, err := git.GitDir() + result, err := loadStack(cfg, "") if err != nil { - cfg.Errorf("not a git repository") - return nil - } - - sf, err := stack.Load(gitDir) - if err != nil { - cfg.Errorf("failed to load stack state: %s", err) - return nil - } - - currentBranch, err := git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) - return nil - } - - s, err := resolveStack(sf, currentBranch, cfg) - if err != nil { - cfg.Errorf("%s", err) - return nil - } - if s == nil { - cfg.Errorf("current branch %q is not part of a stack", currentBranch) - cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) - return nil - } - - // Re-read current branch in case disambiguation caused a checkout - currentBranch, err = git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) return nil } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch // Sync PR state syncStackPRs(cfg, s) From d22f0c3377a126e8ab2b802519728b05133d8ae4 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 09:18:11 -0700 Subject: [PATCH 53/78] clearer wording in navigate --- cmd/navigate.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/navigate.go b/cmd/navigate.go index eece488..52a94b9 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -11,7 +11,7 @@ import ( func UpCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "up [n]", - Short: "Move up in the stack (toward the top)", + Short: "Check out a branch further up in the stack (further from the trunk)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { n := 1 @@ -26,7 +26,7 @@ func UpCmd(cfg *config.Config) *cobra.Command { func DownCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "down [n]", - Short: "Move down in the stack (toward the trunk)", + Short: "Check out a branch further down in the stack (closer to the trunk)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { n := 1 @@ -41,7 +41,7 @@ func DownCmd(cfg *config.Config) *cobra.Command { func TopCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "top", - Short: "Move to the top of the stack", + Short: "Check out the top branch of the stack (furthest from the trunk)", RunE: func(cmd *cobra.Command, args []string) error { return runNavigateToEnd(cfg, true) }, @@ -51,7 +51,7 @@ func TopCmd(cfg *config.Config) *cobra.Command { func BottomCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "bottom", - Short: "Move to the bottom of the stack", + Short: "Check out the bottom branch of the stack (closest to the trunk)", RunE: func(cmd *cobra.Command, args []string) error { return runNavigateToEnd(cfg, false) }, @@ -84,7 +84,7 @@ func runNavigate(cfg *config.Config, delta int) error { cfg.Successf("Switched to %s", target) return nil } - cfg.Printf("already at the bottom of the stack") + cfg.Printf("Already at the bottom of the stack") return nil } cfg.Errorf("current branch %q is not in the stack", currentBranch) @@ -177,7 +177,7 @@ func runNavigate(cfg *config.Config, delta int) error { moved = -moved } - cfg.Successf("Moved %s %d %s to %s", direction, moved, plural(moved, "branch", "branches"), target) + cfg.Successf("Checked out %s, %d %s %s", target, moved, plural(moved, "branch", "branches"), direction) return nil } From 7bb5ca271cbff20b2c4e2b13fb7a64f2dcd0d58f Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 09:38:08 -0700 Subject: [PATCH 54/78] move active branch indices to stack --- cmd/navigate.go | 37 ++++++++++++++---------------------- internal/stack/stack.go | 11 +++++++++++ internal/stack/stack_test.go | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/cmd/navigate.go b/cmd/navigate.go index 52a94b9..c459a9d 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -68,26 +68,22 @@ func runNavigate(cfg *config.Config, delta int) error { idx := s.IndexOf(currentBranch) if idx < 0 { - // Might be on the trunk - if currentBranch == s.Trunk.Branch { - if delta > 0 && len(s.Branches) > 0 { - targetIdx := s.FirstActiveBranchIndex() - if targetIdx < 0 { - // All merged — fall back to top branch with warning - targetIdx = len(s.Branches) - 1 - cfg.Warningf("Warning: all branches in this stack have been merged") - } - target := s.Branches[targetIdx].Branch - if err := git.CheckoutBranch(target); err != nil { - return err - } - cfg.Successf("Switched to %s", target) - return nil + // Current branch is the trunk (not in s.Branches). + // loadStack guarantees the branch is part of the stack. + if delta > 0 && len(s.Branches) > 0 { + targetIdx := s.FirstActiveBranchIndex() + if targetIdx < 0 { + targetIdx = len(s.Branches) - 1 + cfg.Warningf("Warning: all branches in this stack have been merged") } - cfg.Printf("Already at the bottom of the stack") + target := s.Branches[targetIdx].Branch + if err := git.CheckoutBranch(target); err != nil { + return err + } + cfg.Successf("Switched to %s", target) return nil } - cfg.Errorf("current branch %q is not in the stack", currentBranch) + cfg.Printf("Already at the bottom of the stack") return nil } @@ -110,12 +106,7 @@ func runNavigate(cfg *config.Config, delta int) error { } } else { // Build list of active (non-merged) branch indices - var activeIndices []int - for i, b := range s.Branches { - if !b.IsMerged() { - activeIndices = append(activeIndices, i) - } - } + activeIndices := s.ActiveBranchIndices() // Find current position in active list activePos := -1 diff --git a/internal/stack/stack.go b/internal/stack/stack.go index f35c83d..ef7296b 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -124,6 +124,17 @@ func (s *Stack) FirstActiveBranchIndex() int { return -1 } +// ActiveBranchIndices returns the indices of all non-merged branches. +func (s *Stack) ActiveBranchIndices() []int { + var indices []int + for i, b := range s.Branches { + if !b.IsMerged() { + indices = append(indices, i) + } + } + return indices +} + // ActiveBaseBranch returns the effective parent for a branch, skipping merged // ancestors. For the first active branch (or any branch whose downstack is all // merged), this returns the trunk. diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go index d20f1a3..3fa65de 100644 --- a/internal/stack/stack_test.go +++ b/internal/stack/stack_test.go @@ -192,6 +192,39 @@ func TestFirstActiveBranchIndex(t *testing.T) { }) } +// --- ActiveBranchIndices: navigation --- + +func TestActiveBranchIndices(t *testing.T) { + t.Run("all active", func(t *testing.T) { + s := makeStack("main", "b1", "b2", "b3") + assert.Equal(t, []int{0, 1, 2}, s.ActiveBranchIndices()) + }) + + t.Run("some merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + {Branch: "b2"}, + makeMergedBranch("b3", 3), + {Branch: "b4"}, + }, + } + assert.Equal(t, []int{1, 3}, s.ActiveBranchIndices()) + }) + + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.Empty(t, s.ActiveBranchIndices()) + }) +} + // --- Load / Save round-trip persistence --- func TestLoad_Save_RoundTrip(t *testing.T) { From 7144d88d70ea47be9ad75e720b0db6c31cdc8a35 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 14:01:16 -0700 Subject: [PATCH 55/78] politely enable rerere and cache decline --- cmd/init.go | 2 +- cmd/init_test.go | 11 +++--- cmd/rebase.go | 2 +- cmd/sync.go | 2 +- cmd/utils.go | 32 ++++++++++++++++ cmd/utils_test.go | 79 ++++++++++++++++++++++++++++++++++++++++ internal/git/git.go | 15 ++++++++ internal/git/gitops.go | 24 ++++++++++++ internal/git/mock_ops.go | 24 ++++++++++++ 9 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 cmd/utils_test.go diff --git a/cmd/init.go b/cmd/init.go index 92cab0e..0452252 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -58,7 +58,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { trunk := opts.base // Enable git rerere so conflict resolutions are remembered. - _ = git.EnableRerere() + ensureRerere(cfg) if trunk == "" { trunk, err = git.DefaultBranch() diff --git a/cmd/init_test.go b/cmd/init_test.go index 5a40977..4ebea16 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -140,15 +140,16 @@ func TestInit_PrefixStoredInStack(t *testing.T) { } } -func TestInit_EnablesRerere(t *testing.T) { +func TestInit_RerereAlreadyEnabled(t *testing.T) { gitDir := t.TempDir() - rerereCalled := false + enableRerereCalled := false restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, DefaultBranchFn: func() (string, error) { return "main", nil }, CurrentBranchFn: func() (string, error) { return "main", nil }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, EnableRerereFn: func() error { - rerereCalled = true + enableRerereCalled = true return nil }, }) @@ -158,8 +159,8 @@ func TestInit_EnablesRerere(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"b1"}}) collectOutput(cfg, outR, errR) - if !rerereCalled { - t.Error("expected EnableRerere to be called") + if enableRerereCalled { + t.Error("EnableRerere should not be called when rerere is already enabled") } } diff --git a/cmd/rebase.go b/cmd/rebase.go index faee83a..caf0cdb 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -88,7 +88,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { currentBranch := result.CurrentBranch // Enable git rerere so conflict resolutions are remembered. - _ = git.EnableRerere() + ensureRerere(cfg) // Resolve remote for fetch and trunk comparison remote, err := pickRemote(cfg, currentBranch) diff --git a/cmd/sync.go b/cmd/sync.go index beeef6c..486b8e9 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -58,7 +58,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // --- Step 1: Fetch --- // Enable git rerere so conflict resolutions are remembered. - _ = git.EnableRerere() + ensureRerere(cfg) if err := git.Fetch(remote); err != nil { cfg.Warningf("Failed to fetch %s: %v", remote, err) diff --git a/cmd/utils.go b/cmd/utils.go index 56562e6..ba0f64c 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -212,3 +212,35 @@ func activeBranchNames(s *stack.Stack) []string { } return names } + +// ensureRerere checks whether git rerere is enabled and, if not, prompts the +// user for permission before enabling it. If the user previously declined, +// the prompt is suppressed. In non-interactive sessions the function is a +// no-op so commands can still run in CI/scripting. +func ensureRerere(cfg *config.Config) { + enabled, err := git.IsRerereEnabled() + if err != nil || enabled { + return + } + + declined, _ := git.IsRerereDeclined() + if declined { + return + } + + if !cfg.IsInteractive() { + return + } + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + ok, err := p.Confirm("Enable git rerere to remember conflict resolutions?", true) + if err != nil { + return + } + + if ok { + _ = git.EnableRerere() + } else { + _ = git.SaveRerereDeclined() + } +} diff --git a/cmd/utils_test.go b/cmd/utils_test.go new file mode 100644 index 0000000..7cee9a7 --- /dev/null +++ b/cmd/utils_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" +) + +func TestEnsureRerere_SkipsWhenAlreadyEnabled(t *testing.T) { + enableCalled := false + restore := git.SetOps(&git.MockOps{ + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + EnableRerereFn: func() error { + enableCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + ensureRerere(cfg) + collectOutput(cfg, outR, errR) + + if enableCalled { + t.Error("EnableRerere should not be called when already enabled") + } +} + +func TestEnsureRerere_SkipsWhenDeclined(t *testing.T) { + enableCalled := false + restore := git.SetOps(&git.MockOps{ + IsRerereEnabledFn: func() (bool, error) { return false, nil }, + IsRerereDeclinedFn: func() (bool, error) { return true, nil }, + EnableRerereFn: func() error { + enableCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + ensureRerere(cfg) + collectOutput(cfg, outR, errR) + + if enableCalled { + t.Error("EnableRerere should not be called when user previously declined") + } +} + +func TestEnsureRerere_SkipsWhenNonInteractive(t *testing.T) { + enableCalled := false + declinedSaved := false + restore := git.SetOps(&git.MockOps{ + IsRerereEnabledFn: func() (bool, error) { return false, nil }, + IsRerereDeclinedFn: func() (bool, error) { return false, nil }, + EnableRerereFn: func() error { + enableCalled = true + return nil + }, + SaveRerereDeclinedFn: func() error { + declinedSaved = true + return nil + }, + }) + defer restore() + + // NewTestConfig is non-interactive (pipes, not a TTY). + cfg, outR, errR := config.NewTestConfig() + ensureRerere(cfg) + collectOutput(cfg, outR, errR) + + if enableCalled { + t.Error("EnableRerere should not be called in non-interactive mode") + } + if declinedSaved { + t.Error("SaveRerereDeclined should not be called in non-interactive mode") + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 3840103..2309862 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -149,6 +149,21 @@ func EnableRerere() error { return ops.EnableRerere() } +// IsRerereEnabled returns whether rerere.enabled is set to "true" in git config. +func IsRerereEnabled() (bool, error) { + return ops.IsRerereEnabled() +} + +// IsRerereDeclined returns whether the user previously declined the rerere prompt. +func IsRerereDeclined() (bool, error) { + return ops.IsRerereDeclined() +} + +// SaveRerereDeclined records that the user declined the rerere prompt. +func SaveRerereDeclined() error { + return ops.SaveRerereDeclined() +} + // RebaseOnto rebases a branch using the three-argument form: // // git rebase --onto diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 97bf5a4..5b497b1 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -27,6 +27,9 @@ type Ops interface { ResolveRemote(branch string) (string, error) Rebase(base string) error EnableRerere() error + IsRerereEnabled() (bool, error) + IsRerereDeclined() (bool, error) + SaveRerereDeclined() error RebaseOnto(newBase, oldBase, branch string) error RebaseContinue() error RebaseAbort() error @@ -172,6 +175,27 @@ func (d *defaultOps) EnableRerere() error { return runSilent("config", "rerere.autoupdate", "true") } +func (d *defaultOps) IsRerereEnabled() (bool, error) { + out, err := run("config", "--get", "rerere.enabled") + if err != nil { + // Missing key — not enabled. + return false, nil + } + return strings.EqualFold(strings.TrimSpace(out), "true"), nil +} + +func (d *defaultOps) IsRerereDeclined() (bool, error) { + out, err := run("config", "--get", "gh-stack.rerere-declined") + if err != nil { + return false, nil + } + return strings.EqualFold(strings.TrimSpace(out), "true"), nil +} + +func (d *defaultOps) SaveRerereDeclined() error { + return runSilent("config", "gh-stack.rerere-declined", "true") +} + func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string) error { err := runSilent("rebase", "--onto", newBase, oldBase, branch) if err == nil { diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 79f4bd4..323e71b 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -15,6 +15,9 @@ type MockOps struct { ResolveRemoteFn func(string) (string, error) RebaseFn func(string) error EnableRerereFn func() error + IsRerereEnabledFn func() (bool, error) + IsRerereDeclinedFn func() (bool, error) + SaveRerereDeclinedFn func() error RebaseOntoFn func(string, string, string) error RebaseContinueFn func() error RebaseAbortFn func() error @@ -121,6 +124,27 @@ func (m *MockOps) EnableRerere() error { return nil } +func (m *MockOps) IsRerereEnabled() (bool, error) { + if m.IsRerereEnabledFn != nil { + return m.IsRerereEnabledFn() + } + return false, nil +} + +func (m *MockOps) IsRerereDeclined() (bool, error) { + if m.IsRerereDeclinedFn != nil { + return m.IsRerereDeclinedFn() + } + return false, nil +} + +func (m *MockOps) SaveRerereDeclined() error { + if m.SaveRerereDeclinedFn != nil { + return m.SaveRerereDeclinedFn() + } + return nil +} + func (m *MockOps) RebaseOnto(newBase, oldBase, branch string) error { if m.RebaseOntoFn != nil { return m.RebaseOntoFn(newBase, oldBase, branch) From 47d1a3faa28be8fde8739c67bce864b6d96e91a1 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 14:20:14 -0700 Subject: [PATCH 56/78] use full commit sha --- cmd/add.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index bef2778..255ddb7 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -108,7 +108,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { cfg.Errorf("failed to commit: %s", err) return nil } - cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha[:7]), currentBranch) + cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), currentBranch) cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch) cfg.Printf("When you're ready for the next layer, run %s again", cfg.ColorCyan("gh stack add")) return nil @@ -213,7 +213,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { cfg.Errorf("failed to commit: %s", err) return nil } - commitSHA = sha[:7] + commitSHA = sha } if err := stack.Save(gitDir, sf); err != nil { From 5ea2ec8cb35fac7055806d5d44617787ce456c31 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 16:45:28 -0600 Subject: [PATCH 57/78] clean up chained branches display --- cmd/checkout.go | 6 +++--- cmd/init.go | 14 +------------- cmd/rebase.go | 6 +----- cmd/utils.go | 4 ++-- internal/stack/stack.go | 11 ++++++----- 5 files changed, 13 insertions(+), 28 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index cd473de..40f5d95 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -89,7 +89,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { currentBranch, _ := git.CurrentBranch() if targetBranch == currentBranch { cfg.Infof("Already on %s", targetBranch) - cfg.Printf("Stack: %s", s.DisplayName()) + cfg.Printf("Stack: %s", s.DisplayChain()) return nil } @@ -99,7 +99,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { } cfg.Successf("Switched to %s", targetBranch) - cfg.Printf("Stack: %s", s.DisplayName()) + cfg.Printf("Stack: %s", s.DisplayChain()) return nil } @@ -148,7 +148,7 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta options := make([]string, len(sf.Stacks)) for i := range sf.Stacks { - options[i] = sf.Stacks[i].DisplayName() + options[i] = sf.Stacks[i].DisplayChain() } p := prompter.New(cfg.In, cfg.Out, cfg.Err) diff --git a/cmd/init.go b/cmd/init.go index 0452252..cf1fdcf 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -245,11 +245,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Print result if opts.adopt { cfg.Printf("Adopting stack with trunk %s and %d branches", trunk, len(branches)) - chainParts := []string{"(" + trunk + ")"} - for _, b := range branches { - chainParts = append(chainParts, b) - } - cfg.Printf("Initializing stack: %s", joinChain(chainParts)) + cfg.Printf("Initializing stack: %s", newStack.DisplayChain()) cfg.Printf("You can continue working on %s", branches[len(branches)-1]) } else { cfg.Successf("Creating stack with trunk %s and branch %s", trunk, branches[len(branches)-1]) @@ -272,11 +268,3 @@ func runInit(cfg *config.Config, opts *initOptions) error { return nil } -// joinChain formats branches as: (trunk) <- branch1 <- branch2 -func joinChain(parts []string) string { - result := parts[0] - for _, p := range parts[1:] { - result += " <- " + p - } - return result -} diff --git a/cmd/rebase.go b/cmd/rebase.go index caf0cdb..2f0684a 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -132,11 +132,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } - chainParts := []string{s.Trunk.Branch} - for _, b := range s.Branches { - chainParts = append(chainParts, b.Branch) - } - cfg.Printf("Stack detected: %s", joinChain(chainParts)) + cfg.Printf("Stack detected: %s", s.DisplayChain()) currentIdx := s.IndexOf(currentBranch) if currentIdx < 0 { diff --git a/cmd/utils.go b/cmd/utils.go index ba0f64c..ebd35f9 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -99,7 +99,7 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac options := make([]string, len(stacks)) for i, s := range stacks { - options[i] = s.DisplayName() + options[i] = s.DisplayChain() } p := prompter.New(cfg.In, cfg.Out, cfg.Err) @@ -111,7 +111,7 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac s := stacks[selected] if len(s.Branches) == 0 { - return nil, fmt.Errorf("selected stack %q has no branches", s.DisplayName()) + return nil, fmt.Errorf("selected stack %q has no branches", s.DisplayChain()) } // Switch to the top branch of the selected stack so future commands diff --git a/internal/stack/stack.go b/internal/stack/stack.go index ef7296b..2456e1a 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) const ( @@ -40,14 +41,14 @@ type Stack struct { Branches []BranchRef `json:"branches"` } -// DisplayName returns a human-readable chain representation of the stack. +// DisplayChain returns a human-readable chain representation of the stack. // Format: (trunk) <- branch1 <- branch2 <- branch3 -func (s *Stack) DisplayName() string { - result := "(" + s.Trunk.Branch + ")" +func (s *Stack) DisplayChain() string { + parts := []string{"(" + s.Trunk.Branch + ")"} for _, b := range s.Branches { - result += " <- " + b.Branch + parts = append(parts, b.Branch) } - return result + return strings.Join(parts, " <- ") } // BranchNames returns the list of branch names in order. From 0a55e35b8bca21f9aa357550579beb2560f0d6d5 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 17:11:48 -0600 Subject: [PATCH 58/78] block adopting branches with existing PRs --- cmd/init.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index cf1fdcf..af96ad1 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -32,8 +32,8 @@ Unless specified, prompts user to create/select branch for first layer of the st Trunk defaults to default branch, unless specified otherwise.`, Example: ` $ gh stack init $ gh stack init myBranch - $ gh stack init branch1 branch2 branch3 --adopt - $ gh stack init firstBranch -b integrationBranch`, + $ gh stack init --adopt branch1 branch2 branch3 + $ gh stack init --base integrationBranch firstBranch`, RunE: func(cmd *cobra.Command, args []string) error { opts.branches = args return runInit(cfg, opts) @@ -114,6 +114,25 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } branches = opts.branches + + // Check if any adopted branches already have PRs on GitHub. + // If offline or unable to create client, skip silently. + if client, clientErr := cfg.GitHubClient(); clientErr == nil { + for _, b := range branches { + pr, err := client.FindAnyPRForBranch(b) + if err != nil { + continue + } + if pr != nil { + state := "open" + if pr.Merged { + state = "merged" + } + cfg.Errorf("branch %q already has a %s PR (#%d: %s)", b, state, pr.Number, pr.URL) + return nil + } + } + } } else if len(opts.branches) > 0 { // Explicit branch names provided — create them for _, b := range opts.branches { @@ -267,4 +286,3 @@ func runInit(cfg *config.Config, opts *initOptions) error { return nil } - From 24e18bd088bd880aaee1669f9986397cff820bd9 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 19:27:22 -0600 Subject: [PATCH 59/78] Handle SIGINT gracefully during interactive prompts When Ctrl+C is pressed during a prompter interaction, the CLI now prints a friendly 'Received interrupt, aborting operation' message instead of ugly wrapped errors like 'failed to read prefix: could not prompt: interrupt'. Changes: - Add isInterruptError(), printInterrupt(), and errInterrupt sentinel to cmd/utils.go for centralized interrupt detection - Update all 8 prompt sites (init, push, checkout, utils) to detect survey's terminal.InterruptErr and exit cleanly - Update callers of resolveStack, pickRemote, and ensureRerere to propagate interrupt without double-printing errors - Change ensureRerere signature to return error so callers can abort on interrupt - Add tests for interrupt detection helpers Closes td-746bdc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/add.go | 11 ++++++-- cmd/checkout.go | 9 ++++++- cmd/init.go | 17 +++++++++++- cmd/push.go | 16 +++++++++-- cmd/rebase.go | 9 +++++-- cmd/sync.go | 9 +++++-- cmd/utils.go | 44 ++++++++++++++++++++++++++---- cmd/utils_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 165 insertions(+), 18 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 255ddb7..a2de1aa 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -150,10 +151,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) { branchName = branch.NextNumberedName(s.Prefix, existingBranches) } else { - fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ") - if _, err := fmt.Fscan(cfg.In, &branchName); err != nil { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + input, err := p.Input("Enter a name for the new branch", "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil + } return fmt.Errorf("could not read branch name: %w", err) } + branchName = input if s.Prefix != "" && branchName != "" { branchName = s.Prefix + "/" + branchName cfg.Infof("Branch name prefixed: %s", branchName) diff --git a/cmd/checkout.go b/cmd/checkout.go index 40f5d95..c2d09c4 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "strconv" @@ -69,7 +70,9 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { // Interactive picker mode s, err = interactiveStackPicker(cfg, sf) if err != nil { - cfg.Errorf("%s", err) + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } return nil } if s == nil { @@ -158,6 +161,10 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta options, ) if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, errInterrupt + } return nil, fmt.Errorf("stack selection: %w", err) } diff --git a/cmd/init.go b/cmd/init.go index af96ad1..277ef6f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "strings" @@ -58,7 +59,9 @@ func runInit(cfg *config.Config, opts *initOptions) error { trunk := opts.base // Enable git rerere so conflict resolutions are remembered. - ensureRerere(cfg) + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return nil + } if trunk == "" { trunk, err = git.DefaultBranch() @@ -160,6 +163,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { if opts.prefix == "" { prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "") if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil + } cfg.Errorf("failed to read prefix: %s", err) return nil } @@ -174,6 +181,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { true, ) if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil + } cfg.Errorf("failed to confirm branch selection: %s", err) return nil } @@ -193,6 +204,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { } branchName, err := p.Input(prompt, "") if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil + } cfg.Errorf("failed to read branch name: %s", err) return nil } diff --git a/cmd/push.go b/cmd/push.go index d4badd8..b62a522 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -77,7 +77,9 @@ func runPush(cfg *config.Config, opts *pushOptions) error { // Push all active branches atomically remote, err := pickRemote(cfg, currentBranch) if err != nil { - cfg.Errorf("%s", err) + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } return nil } merged := s.MergedBranches() @@ -118,7 +120,13 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if !opts.auto && cfg.IsInteractive() { p := prompter.New(cfg.In, cfg.Out, cfg.Err) input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) - if err == nil && input != "" { + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil + } + // Non-interrupt error: keep the auto-generated title. + } else if input != "" { title = input } } @@ -248,6 +256,10 @@ func pickRemote(cfg *config.Config, branch string) (string, error) { p := prompter.New(cfg.In, cfg.Out, cfg.Err) selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes) if promptErr != nil { + if isInterruptError(promptErr) { + printInterrupt(cfg) + return "", errInterrupt + } return "", fmt.Errorf("remote selection: %w", promptErr) } return multi.Remotes[selected], nil diff --git a/cmd/rebase.go b/cmd/rebase.go index 2f0684a..d7410b1 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -88,12 +89,16 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { currentBranch := result.CurrentBranch // Enable git rerere so conflict resolutions are remembered. - ensureRerere(cfg) + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return nil + } // Resolve remote for fetch and trunk comparison remote, err := pickRemote(cfg, currentBranch) if err != nil { - cfg.Errorf("%s", err) + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } return nil } diff --git a/cmd/sync.go b/cmd/sync.go index 486b8e9..ced9b60 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "strings" @@ -52,13 +53,17 @@ func runSync(cfg *config.Config, _ *syncOptions) error { // Resolve remote once for fetch and push remote, err := pickRemote(cfg, currentBranch) if err != nil { - cfg.Errorf("%s", err) + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } return nil } // --- Step 1: Fetch --- // Enable git rerere so conflict resolutions are remembered. - ensureRerere(cfg) + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return nil + } if err := git.Fetch(remote); err != nil { cfg.Warningf("Failed to fetch %s: %v", remote, err) diff --git a/cmd/utils.go b/cmd/utils.go index ebd35f9..32ef7d2 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -1,14 +1,34 @@ package cmd import ( + "errors" "fmt" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" ) +// errInterrupt is a sentinel returned when a prompt is cancelled via Ctrl+C. +// Callers should exit silently (the friendly message is already printed). +var errInterrupt = errors.New("interrupt") + +// isInterruptError reports whether err is (or wraps) the survey interrupt, +// which is raised when the user presses Ctrl+C during a prompt. +func isInterruptError(err error) bool { + return errors.Is(err, terminal.InterruptErr) +} + +// printInterrupt prints a friendly message and should be called exactly once +// per interrupted operation. The leading newline ensures the message starts +// on its own line even if the cursor was mid-prompt. +func printInterrupt(cfg *config.Config) { + fmt.Fprintln(cfg.Err) + cfg.Infof("Received interrupt, aborting operation") +} + // loadStackResult holds everything returned by loadStack. type loadStackResult struct { GitDir string @@ -46,6 +66,9 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { s, err := resolveStack(sf, branch, cfg) if err != nil { + if errors.Is(err, errInterrupt) { + return nil, errInterrupt + } cfg.Errorf("%s", err) return nil, err } @@ -105,6 +128,10 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac p := prompter.New(cfg.In, cfg.Out, cfg.Err) selected, err := p.Select("Which stack would you like to use?", "", options) if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, errInterrupt + } return nil, fmt.Errorf("stack selection: %w", err) } @@ -217,25 +244,31 @@ func activeBranchNames(s *stack.Stack) []string { // user for permission before enabling it. If the user previously declined, // the prompt is suppressed. In non-interactive sessions the function is a // no-op so commands can still run in CI/scripting. -func ensureRerere(cfg *config.Config) { +// +// Returns errInterrupt if the user pressed Ctrl+C during the prompt. +func ensureRerere(cfg *config.Config) error { enabled, err := git.IsRerereEnabled() if err != nil || enabled { - return + return nil } declined, _ := git.IsRerereDeclined() if declined { - return + return nil } if !cfg.IsInteractive() { - return + return nil } p := prompter.New(cfg.In, cfg.Out, cfg.Err) ok, err := p.Confirm("Enable git rerere to remember conflict resolutions?", true) if err != nil { - return + if isInterruptError(err) { + printInterrupt(cfg) + return errInterrupt + } + return nil } if ok { @@ -243,4 +276,5 @@ func ensureRerere(cfg *config.Config) { } else { _ = git.SaveRerereDeclined() } + return nil } diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 7cee9a7..98890f1 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -1,12 +1,74 @@ package cmd import ( + "errors" + "fmt" + "strings" "testing" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" ) +func TestIsInterruptError_DirectMatch(t *testing.T) { + if !isInterruptError(terminal.InterruptErr) { + t.Error("expected true for terminal.InterruptErr") + } +} + +func TestIsInterruptError_Wrapped(t *testing.T) { + // This is how the prompter library wraps the interrupt error. + wrapped := fmt.Errorf("could not prompt: %w", terminal.InterruptErr) + if !isInterruptError(wrapped) { + t.Error("expected true for wrapped interrupt error") + } +} + +func TestIsInterruptError_DoubleWrapped(t *testing.T) { + // Simulate additional wrapping by callers. + inner := fmt.Errorf("could not prompt: %w", terminal.InterruptErr) + outer := fmt.Errorf("stack selection: %w", inner) + if !isInterruptError(outer) { + t.Error("expected true for double-wrapped interrupt error") + } +} + +func TestIsInterruptError_NonInterrupt(t *testing.T) { + if isInterruptError(errors.New("some other error")) { + t.Error("expected false for non-interrupt error") + } +} + +func TestIsInterruptError_Nil(t *testing.T) { + if isInterruptError(nil) { + t.Error("expected false for nil error") + } +} + +func TestPrintInterrupt_Output(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + printInterrupt(cfg) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "Received interrupt, aborting operation") { + t.Errorf("expected interrupt message, got: %s", output) + } + // Should NOT contain error marker (✗) + if strings.Contains(output, "\u2717") { + t.Errorf("interrupt message should not use error format, got: %s", output) + } +} + +func TestErrInterrupt_IsDistinct(t *testing.T) { + if errors.Is(errInterrupt, terminal.InterruptErr) { + t.Error("errInterrupt sentinel should not match terminal.InterruptErr") + } + if !errors.Is(errInterrupt, errInterrupt) { + t.Error("errInterrupt should match itself") + } +} + func TestEnsureRerere_SkipsWhenAlreadyEnabled(t *testing.T) { enableCalled := false restore := git.SetOps(&git.MockOps{ @@ -19,7 +81,7 @@ func TestEnsureRerere_SkipsWhenAlreadyEnabled(t *testing.T) { defer restore() cfg, outR, errR := config.NewTestConfig() - ensureRerere(cfg) + _ = ensureRerere(cfg) collectOutput(cfg, outR, errR) if enableCalled { @@ -40,7 +102,7 @@ func TestEnsureRerere_SkipsWhenDeclined(t *testing.T) { defer restore() cfg, outR, errR := config.NewTestConfig() - ensureRerere(cfg) + _ = ensureRerere(cfg) collectOutput(cfg, outR, errR) if enableCalled { @@ -67,7 +129,7 @@ func TestEnsureRerere_SkipsWhenNonInteractive(t *testing.T) { // NewTestConfig is non-interactive (pipes, not a TTY). cfg, outR, errR := config.NewTestConfig() - ensureRerere(cfg) + _ = ensureRerere(cfg) collectOutput(cfg, outR, errR) if enableCalled { From 426ad111754fd5779251bae41e382c5b5ff4b66f Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 20:27:37 -0600 Subject: [PATCH 60/78] wrap example commands in quotes for non TTY --- cmd/add.go | 6 +++--- cmd/checkout.go | 2 +- cmd/init.go | 4 ++-- cmd/merge.go | 2 +- cmd/rebase.go | 24 ++++++++++++------------ cmd/sync.go | 8 ++++---- cmd/utils.go | 2 +- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index a2de1aa..4e64a7f 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -67,13 +67,13 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if s.IsFullyMerged() { cfg.Warningf("All branches in this stack have been merged") - cfg.Printf("Consider creating a new stack with %s", cfg.ColorCyan("gh stack init")) + cfg.Printf("Consider creating a new stack with `%s`", cfg.ColorCyan("gh stack init")) return nil } idx := s.IndexOf(currentBranch) if idx >= 0 && idx < len(s.Branches)-1 { - cfg.Errorf("can only add branches on top of the stack; checkout the top branch %q first", s.Branches[len(s.Branches)-1].Branch) + cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch) return nil } @@ -111,7 +111,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), currentBranch) cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch) - cfg.Printf("When you're ready for the next layer, run %s again", cfg.ColorCyan("gh stack add")) + cfg.Printf("When you're ready for the next layer, run `%s` again", cfg.ColorCyan("gh stack add")) return nil } diff --git a/cmd/checkout.go b/cmd/checkout.go index c2d09c4..39540cd 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -145,7 +145,7 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta if len(sf.Stacks) == 0 { cfg.Infof("No locally tracked stacks found") - cfg.Printf("Create a stack with %s or check out a specific branch/PR once server-side discovery is available.", cfg.ColorCyan("gh stack init")) + cfg.Printf("Create a stack with `%s` or check out a specific branch/PR once server-side discovery is available.", cfg.ColorCyan("gh stack init")) return nil, nil } diff --git a/cmd/init.go b/cmd/init.go index 277ef6f..9e278a4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -296,8 +296,8 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } - cfg.Printf("To add a new layer to your stack, run %s", cfg.ColorCyan("gh stack add")) - cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run %s", cfg.ColorCyan("gh stack push")) + cfg.Printf("To add a new layer to your stack, run `%s`", cfg.ColorCyan("gh stack add")) + cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run `%s`", cfg.ColorCyan("gh stack push")) return nil } diff --git a/cmd/merge.go b/cmd/merge.go index 2e318f3..353ec5a 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -26,6 +26,6 @@ func MergeCmd(cfg *config.Config) *cobra.Command { // We need a mergeability check for the entire stack // and an endpoint for merging an entire stack func runMerge(cfg *config.Config, opts struct{}) error { - cfg.Warningf("gh stack merge is not yet implemented") + cfg.Warningf("`%s` is not yet implemented", cfg.ColorCyan("gh stack merge")) return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index d7410b1..76bbb3a 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -238,9 +238,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { printConflictDetails(cfg, newBase) cfg.Printf("") - cfg.Printf("Resolve conflicts on %s, then run %s", + cfg.Printf("Resolve conflicts on %s, then run `%s`", br.Branch, cfg.ColorCyan("gh stack rebase --continue")) - cfg.Printf("Or abort this operation with %s", + cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) return fmt.Errorf("rebase conflict on %s", br.Branch) } @@ -286,9 +286,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { printConflictDetails(cfg, base) cfg.Printf("") - cfg.Printf("Resolve conflicts on %s, then run %s", + cfg.Printf("Resolve conflicts on %s, then run `%s`", br.Branch, cfg.ColorCyan("gh stack rebase --continue")) - cfg.Printf("Or abort this operation with %s", + cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) return fmt.Errorf("rebase conflict on %s", br.Branch) } @@ -322,7 +322,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch) - cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", + cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", cfg.ColorCyan("gh stack push")) return nil @@ -436,9 +436,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { cfg.Warningf("Rebasing %s onto %s — conflict", branchName, newBase) printConflictDetails(cfg, newBase) cfg.Printf("") - cfg.Printf("Resolve conflicts on %s, then run %s", + cfg.Printf("Resolve conflicts on %s, then run `%s`", branchName, cfg.ColorCyan("gh stack rebase --continue")) - cfg.Printf("Or abort this operation with %s", + cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) return fmt.Errorf("rebase conflict on %s", branchName) } @@ -476,9 +476,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { cfg.Warningf("Rebasing %s onto %s — conflict", branchName, base) printConflictDetails(cfg, base) cfg.Printf("") - cfg.Printf("Resolve conflicts on %s, then run %s", + cfg.Printf("Resolve conflicts on %s, then run `%s`", branchName, cfg.ColorCyan("gh stack rebase --continue")) - cfg.Printf("Or abort this operation with %s", + cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) return fmt.Errorf("rebase conflict on %s", branchName) } @@ -497,7 +497,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { _ = stack.Save(gitDir, sf) cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch) - cfg.Printf("To push up your changes and open/update the stack of PRs, run %s", + cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", cfg.ColorCyan("gh stack push")) return nil @@ -594,6 +594,6 @@ func printConflictDetails(cfg *config.Config, branch string) { cfg.Printf(" %s", cfg.ColorCyan("=======")) cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>")) cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers") - cfg.Printf(" 3. Stage resolved files: %s", cfg.ColorCyan("git add ")) - cfg.Printf(" 4. Continue the rebase: %s", cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf(" 3. Stage resolved files: `%s`", cfg.ColorCyan("git add ")) + cfg.Printf(" 4. Continue the rebase: `%s`", cfg.ColorCyan("gh stack rebase --continue")) } diff --git a/cmd/sync.go b/cmd/sync.go index ced9b60..883457a 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -169,7 +169,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, newBase) reportRestoreStatus(cfg, restoreErrors) - cfg.Printf(" Run %s to resolve conflicts interactively.", + cfg.Printf(" Run `%s` to resolve conflicts interactively.", cfg.ColorCyan("gh stack rebase")) conflicted = true break @@ -201,7 +201,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base) reportRestoreStatus(cfg, restoreErrors) - cfg.Printf(" Run %s to resolve conflicts interactively.", + cfg.Printf(" Run `%s` to resolve conflicts interactively.", cfg.ColorCyan("gh stack rebase")) conflicted = true break @@ -235,11 +235,11 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if err := git.Push(remote, branches, force, true); err != nil { if !force { cfg.Warningf("Push failed — branches may need force push after rebase") - cfg.Printf(" Run %s to push with --force-with-lease.", + cfg.Printf(" Run `%s` to push with --force-with-lease.", cfg.ColorCyan("gh stack push")) } else { cfg.Warningf("Push failed: %v", err) - cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push")) + cfg.Printf(" Run `%s` to retry.", cfg.ColorCyan("gh stack push")) } } else { cfg.Successf("Pushed %d branches", len(branches)) diff --git a/cmd/utils.go b/cmd/utils.go index 32ef7d2..6bd75e1 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -78,7 +78,7 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { } else { cfg.Errorf("current branch %q is not part of a stack", branch) } - cfg.Printf("Checkout an existing stack using %s or create a new stack using %s", + cfg.Printf("Checkout an existing stack using `%s` or create a new stack using `%s`", cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) return nil, fmt.Errorf("branch %q is not part of a stack", branch) } From 14364a24085bfe200a52c31b1db6627dee9c0e90 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 22 Mar 2026 21:28:50 -0600 Subject: [PATCH 61/78] waterfall pr lookup by url, number, branch name --- cmd/checkout.go | 35 +----- cmd/merge.go | 106 ++++++++++++++-- cmd/merge_test.go | 312 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/utils.go | 65 ++++++++++ cmd/utils_test.go | 125 +++++++++++++++++++ 5 files changed, 599 insertions(+), 44 deletions(-) create mode 100644 cmd/merge_test.go diff --git a/cmd/checkout.go b/cmd/checkout.go index 39540cd..37aa47c 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -3,7 +3,6 @@ package cmd import ( "errors" "fmt" - "strconv" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" @@ -82,11 +81,13 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { targetBranch = s.Branches[len(s.Branches)-1].Branch } else { // Resolve target against local stacks - s, targetBranch, err = findStackByTarget(sf, opts.target) + var br *stack.BranchRef + s, br, err = resolvePR(sf, opts.target) if err != nil { cfg.Errorf("%s", err) return nil } + targetBranch = br.Branch } currentBranch, _ := git.CurrentBranch() @@ -106,36 +107,6 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } -// findStackByTarget resolves a target string against locally tracked stacks. -// It tries PR number first (integer), then branch name. -func findStackByTarget(sf *stack.StackFile, target string) (*stack.Stack, string, error) { - // Try parsing as a PR number - if prNumber, err := strconv.Atoi(target); err == nil && prNumber > 0 { - s, b := sf.FindStackByPRNumber(prNumber) - if s != nil && b != nil { - return s, b.Branch, nil - } - } - - // Try matching as a branch name - stacks := sf.FindAllStacksForBranch(target) - if len(stacks) == 1 { - return stacks[0], target, nil - } - if len(stacks) > 1 { - // Target is in multiple stacks (e.g. a trunk branch) — return the first one. - // A future improvement could prompt for disambiguation here. - return stacks[0], target, nil - } - - return nil, "", fmt.Errorf( - "no locally tracked stack found for %q\n"+ - "This command currently only works with stacks created locally.\n"+ - "Server-side stack discovery will be available in a future release.", - target, - ) -} - // interactiveStackPicker shows a menu of all locally tracked stacks and returns // the one the user selects. Returns nil, nil if the user has no stacks. func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) { diff --git a/cmd/merge.go b/cmd/merge.go index 353ec5a..e924996 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -1,31 +1,113 @@ package cmd import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/browser" + "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) func MergeCmd(cfg *config.Config) *cobra.Command { - opts := struct{}{} - cmd := &cobra.Command{ - Use: "merge ", + Use: "merge []", Short: "Merge a stack of PRs", - Long: "Merges the specified PR and all PRs below it in the stack.", - Args: cobra.ExactArgs(1), + Long: `Merges the specified PR and all PRs below it in the stack. + +Accepts a PR URL, PR number, or branch name. When run without +arguments, operates on the current branch's PR.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runMerge(cfg, opts) + var target string + if len(args) > 0 { + target = args[0] + } + return runMerge(cfg, target) }, } return cmd } -// runMerge is a placeholder for the stack merge workflow. -// -// We need a mergeability check for the entire stack -// and an endpoint for merging an entire stack -func runMerge(cfg *config.Config, opts struct{}) error { - cfg.Warningf("`%s` is not yet implemented", cfg.ColorCyan("gh stack merge")) +func runMerge(cfg *config.Config, target string) error { + // Standard stack loading and validation. + result, err := loadStack(cfg, "") + if err != nil { + return nil + } + s := result.Stack + currentBranch := result.CurrentBranch + + // Sync PR state from GitHub so merge status is up to date. + syncStackPRs(cfg, s) + + // Persist the refreshed PR state. + _ = stack.Save(result.GitDir, result.StackFile) + + // Resolve which branch to operate on. + var br *stack.BranchRef + if target != "" { + _, br, err = resolvePR(result.StackFile, target) + if err != nil { + cfg.Errorf("%s", err) + return nil + } + } else { + idx := s.IndexOf(currentBranch) + if idx < 0 { + if s.IsFullyMerged() { + cfg.Successf("All PRs in this stack have already been merged") + return nil + } + cfg.Errorf("current branch %q is not a stack branch (it may be the trunk)", currentBranch) + return nil + } + br = &s.Branches[idx] + } + + if br.PullRequest == nil { + cfg.Errorf("no pull request found for branch %q", currentBranch) + cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack push")) + return nil + } + + if br.IsMerged() { + cfg.Successf("PR %s has already been merged", cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) + cfg.Printf(" %s", br.PullRequest.URL) + return nil + } + + prURL := br.PullRequest.URL + prLink := cfg.PRLink(br.PullRequest.Number, prURL) + + cfg.Warningf("Merging stacked PRs from the CLI is not yet supported") + + if cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + openWeb, promptErr := p.Confirm( + fmt.Sprintf("Open %s in your browser?", prLink), true) + if promptErr != nil { + if isInterruptError(promptErr) { + printInterrupt(cfg) + return nil + } + cfg.Errorf("prompt failed: %s", promptErr) + return nil + } + + if openWeb { + b := browser.New("", cfg.Out, cfg.Err) + if err := b.Browse(prURL); err != nil { + cfg.Warningf("failed to open browser: %s", err) + } else { + cfg.Successf("Opened %s in your browser", prLink) + return nil + } + } + } + + cfg.Printf(" You can merge this PR at: %s", prURL) return nil } diff --git a/cmd/merge_test.go b/cmd/merge_test.go new file mode 100644 index 0000000..1c57e09 --- /dev/null +++ b/cmd/merge_test.go @@ -0,0 +1,312 @@ +package cmd + +import ( + "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 newMergeMock(tmpDir, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + } +} + +func TestMerge_NoPullRequest(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "no pull request found") + assert.Contains(t, output, "gh stack push") +} + +func TestMerge_AlreadyMerged(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + Merged: true, + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "already been merged") + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_FullyMergedStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 10, + URL: "https://github.com/owner/repo/pull/10", + Merged: true, + }}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{ + Number: 11, + URL: "https://github.com/owner/repo/pull/11", + Merged: true, + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // On trunk with all PRs merged → fully merged message. + restore := git.SetOps(newMergeMock(tmpDir, "main")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "All PRs in this stack have already been merged") +} + +func TestMerge_OnTrunk(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // Current branch is trunk, not a stack branch. + restore := git.SetOps(newMergeMock(tmpDir, "main")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "not a stack branch") +} + +func TestMerge_NonInteractive_PrintsURL(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + // NewTestConfig is non-interactive (piped output), so no confirm prompt. + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "not yet supported") + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_NoArgs(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"extra-arg", "another"}) + err := cmd.Execute() + + // MaximumNArgs(1) should reject two positional arguments. + assert.Error(t, err) +} + +func TestMerge_ByPRNumber(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{ + Number: 43, + URL: "https://github.com/owner/repo/pull/43", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // Current branch is feat-2, but we target PR #42 (feat-1) via arg. + restore := git.SetOps(newMergeMock(tmpDir, "feat-2")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"42"}) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "not yet supported") + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_ByPRURL(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"https://github.com/owner/repo/pull/42"}) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "not yet supported") + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_ByBranchName(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "main")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"feat-1"}) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "not yet supported") + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} diff --git a/cmd/utils.go b/cmd/utils.go index 6bd75e1..f180909 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -3,6 +3,9 @@ package cmd import ( "errors" "fmt" + "net/url" + "strconv" + "strings" "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/go-gh/v2/pkg/prompter" @@ -240,6 +243,68 @@ func activeBranchNames(s *stack.Stack) []string { return names } +// resolvePR resolves a user-provided target to a stack and branch using +// waterfall logic: PR URL → PR number → branch name. +func resolvePR(sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) { + // Try parsing as a GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). + if prNumber, ok := parsePRURL(target); ok { + s, b := sf.FindStackByPRNumber(prNumber) + if s != nil && b != nil { + return s, b, nil + } + } + + // Try parsing as a PR number. + if prNumber, err := strconv.Atoi(target); err == nil && prNumber > 0 { + s, b := sf.FindStackByPRNumber(prNumber) + if s != nil && b != nil { + return s, b, nil + } + } + + // Try matching as a branch name. + stacks := sf.FindAllStacksForBranch(target) + if len(stacks) > 0 { + s := stacks[0] + idx := s.IndexOf(target) + if idx >= 0 { + return s, &s.Branches[idx], nil + } + // Target matched as trunk — return the first active branch. + if len(s.Branches) > 0 { + return s, &s.Branches[0], nil + } + } + + return nil, nil, fmt.Errorf( + "no locally tracked stack found for %q\n"+ + "This command currently only works with stacks created locally.\n"+ + "Server-side stack discovery will be available in a future release.", + target, + ) +} + +// parsePRURL extracts a PR number from a GitHub pull request URL. +// Returns the number and true if the URL matches, or 0 and false otherwise. +func parsePRURL(raw string) (int, bool) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return 0, false + } + + // Match paths like /owner/repo/pull/123 + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 4 || parts[2] != "pull" { + return 0, false + } + + n, err := strconv.Atoi(parts[3]) + if err != nil || n <= 0 { + return 0, false + } + return n, true +} + // ensureRerere checks whether git rerere is enabled and, if not, prompts the // user for permission before enabling it. If the user previously declined, // the prompt is suppressed. In non-interactive sessions the function is a diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 98890f1..2077c5d 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -9,6 +9,8 @@ import ( "github.com/AlecAivazis/survey/v2/terminal" "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 TestIsInterruptError_DirectMatch(t *testing.T) { @@ -139,3 +141,126 @@ func TestEnsureRerere_SkipsWhenNonInteractive(t *testing.T) { t.Error("SaveRerereDeclined should not be called in non-interactive mode") } } + +func TestResolvePR_ByPRNumber(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"}}, + }, + }, + }, + } + + s, br, err := resolvePR(sf, "42") + assert.NoError(t, err) + assert.Equal(t, "feat-1", br.Branch) + assert.Equal(t, 42, br.PullRequest.Number) + assert.Equal(t, "main", s.Trunk.Branch) +} + +func TestResolvePR_ByPRURL(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + }, + }, + }, + } + + s, br, err := resolvePR(sf, "https://github.com/o/r/pull/42") + assert.NoError(t, err) + assert.Equal(t, "feat-1", br.Branch) + assert.Equal(t, "main", s.Trunk.Branch) +} + +func TestResolvePR_ByBranchName(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 42}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 43}}, + }, + }, + }, + } + + s, br, err := resolvePR(sf, "feat-2") + assert.NoError(t, err) + assert.Equal(t, "feat-2", br.Branch) + assert.Equal(t, 43, br.PullRequest.Number) + assert.Equal(t, "main", s.Trunk.Branch) +} + +func TestResolvePR_NotFound(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat-1"}}, + }, + }, + } + + _, _, err := resolvePR(sf, "nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no locally tracked stack found") +} + +func TestResolvePR_URLPrecedesNumber(t *testing.T) { + // A PR URL that contains number 99 should resolve via URL parsing, + // even if PR #99 doesn't exist — the URL parser extracts the number. + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 99, URL: "https://github.com/o/r/pull/99"}}, + }, + }, + }, + } + + _, br, err := resolvePR(sf, "https://github.com/o/r/pull/99") + assert.NoError(t, err) + assert.Equal(t, 99, br.PullRequest.Number) +} + +func TestParsePRURL(t *testing.T) { + tests := []struct { + name string + input string + wantN int + wantOK bool + }{ + {"standard URL", "https://github.com/owner/repo/pull/42", 42, true}, + {"with trailing slash", "https://github.com/owner/repo/pull/42/", 42, true}, + {"with files tab", "https://github.com/owner/repo/pull/42/files", 42, true}, + {"not a PR URL", "https://github.com/owner/repo/issues/42", 0, false}, + {"plain number", "42", 0, false}, + {"branch name", "feat-1", 0, false}, + {"empty", "", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n, ok := parsePRURL(tt.input) + assert.Equal(t, tt.wantOK, ok) + if ok { + assert.Equal(t, tt.wantN, n) + } + }) + } +} From 50fdc336839e6fae15fcf2ce621bfe5b8034d5f9 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 23 Mar 2026 03:54:12 -0400 Subject: [PATCH 62/78] support commit msg editor, update stage all flag to match git commit, better error handling --- README.md | 20 ++--- cmd/add.go | 171 ++++++++++++++++++++++----------------- cmd/add_test.go | 53 ++++++++---- internal/git/git.go | 16 ++++ internal/git/gitops.go | 9 +++ internal/git/mock_ops.go | 8 ++ 6 files changed, 178 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 236514a..c84ad1d 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,11 @@ You can optionally stage changes and create a commit as part of the `add` flow. | Flag | Description | |------|-------------| -| `-a, --all` | Stage all changes (including untracked files); requires `-m` | +| `-A, --all` | Stage all changes (including untracked files); requires `-m` | | `-u, --update` | Stage changes to tracked files only; requires `-m` | | `-m, --message ` | Create a commit with this message before creating the branch | -> **Note:** `-a` and `-u` are mutually exclusive. +> **Note:** `-A` and `-u` are mutually exclusive. **Examples:** @@ -118,7 +118,7 @@ gh stack add api-routes gh stack add # Stage all changes, commit, and auto-generate the branch name -gh stack add -am "Add login endpoint" +gh stack add -Am "Add login endpoint" # Stage only tracked files, commit, and auto-generate the branch name gh stack add -um "Fix auth bug" @@ -127,7 +127,7 @@ gh stack add -um "Fix auth bug" gh stack add -m "Add user model" # Stage all changes, commit, and use an explicit branch name -gh stack add -am "Add tests" test-layer +gh stack add -Am "Add tests" test-layer # Stage only tracked files, commit, and use an explicit branch name gh stack add -um "Update docs" docs-layer @@ -391,9 +391,9 @@ gh stack sync ## Abbreviated workflow -If you want to minimize keystrokes, use a branch prefix and the `-am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated from your commit messages. +If you want to minimize keystrokes, use a branch prefix and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated from your commit messages. -When a branch has no commits yet (e.g., right after `init`), `add -am` stages and commits directly on that branch instead of creating a new one. Once a branch has commits, `add -am` creates a new branch, checks it out, and commits there. +When a branch has no commits yet (e.g., right after `init`), `add -Am` stages and commits directly on that branch instead of creating a new one. Once a branch has commits, `add -Am` creates a new branch, checks it out, and commits there. ```sh # 1. Start a stack with a prefix @@ -404,7 +404,7 @@ gh stack init -p feat # ... write code ... # 3. Stage and commit on the current branch -gh stack add -am "Auth middleware" +gh stack add -Am "Auth middleware" # → feat/01 has no commits yet, so the commit lands here # (no new branch is created) @@ -412,18 +412,18 @@ gh stack add -am "Auth middleware" # ... write code ... # 5. Create the next branch and commit -gh stack add -am "API routes" +gh stack add -Am "API routes" # → feat/01 already has commits, so a new branch feat/02 is # created, checked out, and the commit lands there # 6. Keep going # ... write code ... -gh stack add -am "Frontend components" +gh stack add -Am "Frontend components" # → feat/02 already has commits, creates feat/03 and commits there # 7. Push everything and create PRs gh stack push ``` -Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -am "..."` does it all. +Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all. diff --git a/cmd/add.go b/cmd/add.go index 4e64a7f..660b761 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -25,20 +25,17 @@ func AddCmd(cfg *config.Config) *cobra.Command { Short: "Add a new branch on top of the current stack", Long: `Add a new branch on top of the current stack. -Optionally stage changes and create a commit before creating the branch: - -a Stage all changes (including untracked) before committing - -u Stage tracked file changes before committing - -m Create a commit with the given message - -When -m is provided without an explicit branch name, the branch name -is auto-generated based on the commit message and stack prefix.`, +When -m is omitted but -A or -u is used, your editor opens for the +commit message. When -m is provided without an explicit branch name, +the branch name is auto-generated based on the commit message and +stack prefix.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runAdd(cfg, opts, args) }, } - cmd.Flags().BoolVarP(&opts.stageAll, "all", "a", false, "Stage all changes including untracked files") + cmd.Flags().BoolVarP(&opts.stageAll, "all", "A", false, "Stage all changes including untracked files") cmd.Flags().BoolVarP(&opts.stageTracked, "update", "u", false, "Stage changes to tracked files only") cmd.Flags().StringVarP(&opts.message, "message", "m", "", "Create a commit with this message") @@ -48,11 +45,7 @@ is auto-generated based on the commit message and stack prefix.`, func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // Validate flag combinations if opts.stageAll && opts.stageTracked { - cfg.Errorf("flags -a and -u are mutually exclusive") - return nil - } - if (opts.stageAll || opts.stageTracked) && opts.message == "" { - cfg.Errorf("staging flags (-a, -u) require -m to create a commit") + cfg.Errorf("flags -A and -u are mutually exclusive") return nil } @@ -72,39 +65,32 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } idx := s.IndexOf(currentBranch) + // idx < 0 means we're on the trunk — that's allowed (we'll create + // a new branch from it). Only block if we're in the middle of the stack. if idx >= 0 && idx < len(s.Branches)-1 { cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch) return nil } - // When -m is provided, check if the current branch is a stack branch with - // no unique commits relative to its parent. If so, the commit should land - // on this branch without creating a new one (e.g., right after init). + // Check if the current branch is a stack branch with no unique commits + // relative to its parent. If so, the commit should land on this branch + // without creating a new one (e.g., right after init). + wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked var branchIsEmpty bool - if opts.message != "" && idx >= 0 { + if wantsCommit && idx >= 0 { parentBranch := s.ActiveBaseBranch(currentBranch) - commits, _ := git.LogRange(parentBranch, currentBranch) - branchIsEmpty = len(commits) == 0 + shas, err := git.RevParseMulti([]string{parentBranch, currentBranch}) + if err == nil { + branchIsEmpty = shas[0] == shas[1] + } } // Empty branch path: stage and commit here, don't create a new branch. - if branchIsEmpty && opts.message != "" { - if opts.stageAll { - if err := git.StageAll(); err != nil { - cfg.Errorf("failed to stage changes: %s", err) - return nil - } - } else if opts.stageTracked { - if err := git.StageTracked(); err != nil { - cfg.Errorf("failed to stage changes: %s", err) - return nil - } - } - if !git.HasStagedChanges() { - cfg.Errorf("nothing to commit; stage changes first or use -a/-u") + if branchIsEmpty { + if err := stageAndValidate(cfg, opts); err != nil { return nil } - sha, err := git.Commit(opts.message) + sha, err := doCommit(opts.message) if err != nil { cfg.Errorf("failed to commit: %s", err) return nil @@ -121,10 +107,10 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if len(args) > 0 { explicitName = args[0] } + existingBranches := s.BranchNames() if opts.message != "" { // Auto-naming mode - existingBranches := s.BranchNames() isFirstBranch := len(existingBranches) == 0 name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, isFirstBranch) if name == "" { @@ -136,34 +122,30 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { cfg.Infof("%s", info) } } else if explicitName != "" { - // No -m, but explicit name given - if s.Prefix != "" { - branchName = s.Prefix + "/" + explicitName - cfg.Infof("Branch name prefixed: %s", branchName) - } else { - branchName = explicitName - } + branchName = applyPrefix(cfg, s.Prefix, explicitName) } else { // No -m, no explicit name — auto-generate if following numbered // convention, otherwise prompt for a name. - existingBranches := s.BranchNames() if s.Prefix != "" && len(existingBranches) > 0 && branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) { branchName = branch.NextNumberedName(s.Prefix, existingBranches) } else { p := prompter.New(cfg.In, cfg.Out, cfg.Err) - input, err := p.Input("Enter a name for the new branch", "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return nil + for { + input, err := p.Input("Enter a name for the new branch", "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil + } + return fmt.Errorf("could not read branch name: %w", err) } - return fmt.Errorf("could not read branch name: %w", err) - } - branchName = input - if s.Prefix != "" && branchName != "" { - branchName = s.Prefix + "/" + branchName - cfg.Infof("Branch name prefixed: %s", branchName) + if input == "" { + cfg.Warningf("branch name cannot be empty, please try again") + continue + } + branchName = applyPrefix(cfg, s.Prefix, input) + break } } } @@ -179,10 +161,18 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } if git.BranchExists(branchName) { - cfg.Errorf("branch %q already exists; provide an explicit name", branchName) + cfg.Errorf("branch %q already exists", branchName) return nil } + // Stage changes before creating the branch so we can fail early if + // there's nothing to commit (avoids leaving an empty orphan branch). + if wantsCommit { + if err := stageAndValidate(cfg, opts); err != nil { + return nil + } + } + // Create the new branch from the current HEAD and check it out if err := git.CreateBranch(branchName, currentBranch); err != nil { cfg.Errorf("failed to create branch: %s", err) @@ -194,28 +184,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return nil } - base, _ := git.RevParse(currentBranch) + base, err := git.RevParse(currentBranch) + if err != nil { + cfg.Warningf("could not resolve base SHA for %s: %s", currentBranch, err) + } s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base}) - // Stage and commit on the NEW branch if -m is provided + // Commit on the NEW branch (staging already done above) var commitSHA string - if opts.message != "" { - if opts.stageAll { - if err := git.StageAll(); err != nil { - cfg.Errorf("failed to stage changes: %s", err) - return nil - } - } else if opts.stageTracked { - if err := git.StageTracked(); err != nil { - cfg.Errorf("failed to stage changes: %s", err) - return nil - } - } - if !git.HasStagedChanges() { - cfg.Errorf("nothing to commit; stage changes first or use -a/-u") - return nil - } - sha, err := git.Commit(opts.message) + if wantsCommit { + sha, err := doCommit(opts.message) if err != nil { cfg.Errorf("failed to commit: %s", err) return nil @@ -238,3 +216,48 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return nil } + +// stageAndValidate stages files (if -A or -u is set) and verifies there are +// staged changes to commit. Prints a user-facing error and returns non-nil +// if staging fails or there is nothing to commit. +func stageAndValidate(cfg *config.Config, opts *addOptions) error { + if opts.stageAll { + if err := git.StageAll(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return err + } + } else if opts.stageTracked { + if err := git.StageTracked(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return err + } + } + + if !git.HasStagedChanges() { + if opts.stageAll || opts.stageTracked { + cfg.Errorf("no changes to commit after staging") + } else { + cfg.Errorf("nothing to commit; stage changes first or use -A/-u") + } + return fmt.Errorf("nothing to commit") + } + return nil +} + +// doCommit commits staged changes. If message is provided, uses it directly. +// If message is empty, launches the user's editor via git commit. +func doCommit(message string) (string, error) { + if message != "" { + return git.Commit(message) + } + return git.CommitInteractive() +} + +// applyPrefix prepends the stack prefix to a branch name if set. +func applyPrefix(cfg *config.Config, prefix, name string) string { + if prefix != "" { + name = prefix + "/" + name + cfg.Infof("Branch name prefixed: %s", name) + } + return name +} diff --git a/cmd/add_test.go b/cmd/add_test.go index e7c9667..c1efb58 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -106,16 +106,37 @@ func TestAdd_MutuallyExclusiveFlags(t *testing.T) { } } -func TestAdd_StagingRequiresMessage(t *testing.T) { - restore := git.SetOps(&git.MockOps{}) +func TestAdd_StagingWithoutMessageUsesEditor(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + interactiveCalled := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + return []string{"aaa", "bbb"}, nil + }, + RevParseFn: func(ref string) (string, error) { return "abc", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + CheckoutBranchFn: func(name string) error { return nil }, + StageAllFn: func() error { return nil }, + HasStagedChangesFn: func() bool { return true }, + CommitInteractiveFn: func() (string, error) { + interactiveCalled = true + return "def1234567890", nil + }, + }) defer restore() - cfg, outR, errR := config.NewTestConfig() - runAdd(cfg, &addOptions{stageAll: true}, []string{"branch"}) - output := collectOutput(cfg, outR, errR) + cfg, _, _ := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true}, []string{"new-branch"}) - if !strings.Contains(output, "require -m") { - t.Errorf("expected 'require -m' error, got: %s", output) + if !interactiveCalled { + t.Error("expected CommitInteractive to be called when -m is omitted") } } @@ -184,8 +205,9 @@ func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) { restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "b1", nil }, - LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { - return []git.CommitInfo{{SHA: "abc1234567890", Subject: "existing"}}, nil + RevParseMultiFn: func(refs []string) ([]string, error) { + // Parent and current branch point to different commits (branch has commits) + return []string{"aaa", "bbb"}, nil }, CreateBranchFn: func(name, base string) error { createCalled = true @@ -264,8 +286,8 @@ func TestAdd_NumberedNaming(t *testing.T) { restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "feat/01", nil }, - LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { - return []git.CommitInfo{{SHA: "abc1234567890", Subject: "existing"}}, nil + RevParseMultiFn: func(refs []string) ([]string, error) { + return []string{"aaa", "bbb"}, nil }, CreateBranchFn: func(name, base string) error { createdBranch = name @@ -325,9 +347,10 @@ func TestAdd_NothingToCommit(t *testing.T) { restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "b1", nil }, - LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { - return nil, nil // empty branch + RevParseMultiFn: func(refs []string) ([]string, error) { + return []string{"aaa", "aaa"}, nil // same SHA = empty branch }, + StageAllFn: func() error { return nil }, HasStagedChangesFn: func() bool { return false }, }) defer restore() @@ -336,7 +359,7 @@ func TestAdd_NothingToCommit(t *testing.T) { runAdd(cfg, &addOptions{stageAll: true, message: "msg"}, nil) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "nothing to commit") { - t.Errorf("expected 'nothing to commit' error, got: %s", output) + if !strings.Contains(output, "no changes to commit") { + t.Errorf("expected 'no changes to commit' error, got: %s", output) } } diff --git a/internal/git/git.go b/internal/git/git.go index 2309862..8af505d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -54,6 +54,16 @@ func runSilent(args ...string) error { return cmd.Run() } +// runInteractive runs a git command with stdin/stdout/stderr connected to +// the terminal, allowing interactive programs like editors to work. +func runInteractive(args ...string) error { + cmd := exec.Command("git", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // rebaseContinueOnce runs a single git rebase --continue without auto-resolve. func rebaseContinueOnce() error { cmd := exec.Command("git", "rebase", "--continue") @@ -331,6 +341,12 @@ func Commit(message string) (string, error) { return ops.Commit(message) } +// CommitInteractive launches the user's configured editor for the commit +// message, equivalent to running `git commit` without `-m`. +func CommitInteractive() (string, error) { + return ops.CommitInteractive() +} + // ValidateRefName checks whether name is a valid git branch name. func ValidateRefName(name string) error { return ops.ValidateRefName(name) diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 5b497b1..d6ba52d 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -54,6 +54,7 @@ type Ops interface { StageTracked() error HasStagedChanges() bool Commit(message string) (string, error) + CommitInteractive() (string, error) ValidateRefName(name string) error } @@ -481,6 +482,14 @@ func (d *defaultOps) Commit(message string) (string, error) { return run("rev-parse", "HEAD") } +// CommitInteractive launches the user's editor for the commit message. +func (d *defaultOps) CommitInteractive() (string, error) { + if err := runInteractive("commit"); err != nil { + return "", err + } + return run("rev-parse", "HEAD") +} + func (d *defaultOps) ValidateRefName(name string) error { _, err := run("check-ref-format", "--branch", name) return err diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 323e71b..a8ab662 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -42,6 +42,7 @@ type MockOps struct { StageTrackedFn func() error HasStagedChangesFn func() bool CommitFn func(string) (string, error) + CommitInteractiveFn func() (string, error) ValidateRefNameFn func(string) error } @@ -322,6 +323,13 @@ func (m *MockOps) Commit(message string) (string, error) { return "", nil } +func (m *MockOps) CommitInteractive() (string, error) { + if m.CommitInteractiveFn != nil { + return m.CommitInteractiveFn() + } + return "", nil +} + func (m *MockOps) ValidateRefName(name string) error { if m.ValidateRefNameFn != nil { return m.ValidateRefNameFn(name) From 1a1f49f2fbebf60bbd334ab8459c773b5c394f7f Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 23 Mar 2026 04:22:45 -0400 Subject: [PATCH 63/78] exit with nonzero status on error --- cmd/add.go | 30 ++++++++++++++--------------- cmd/checkout.go | 10 +++++----- cmd/init.go | 48 +++++++++++++++++++++++----------------------- cmd/merge.go | 8 ++++---- cmd/merge_test.go | 4 ++-- cmd/navigate.go | 6 +++--- cmd/push.go | 20 +++++++++---------- cmd/rebase.go | 20 +++++++++---------- cmd/rebase_test.go | 2 +- cmd/root.go | 5 ++++- cmd/sync.go | 8 ++++---- cmd/unstack.go | 6 +++--- cmd/utils.go | 4 ++++ cmd/view.go | 2 +- 14 files changed, 90 insertions(+), 83 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 660b761..261fe5c 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -46,12 +46,12 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // Validate flag combinations if opts.stageAll && opts.stageTracked { cfg.Errorf("flags -A and -u are mutually exclusive") - return nil + return ErrSilent } result, err := loadStack(cfg, "") if err != nil { - return nil + return ErrSilent } gitDir := result.GitDir sf := result.StackFile @@ -69,7 +69,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // a new branch from it). Only block if we're in the middle of the stack. if idx >= 0 && idx < len(s.Branches)-1 { cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch) - return nil + return ErrSilent } // Check if the current branch is a stack branch with no unique commits @@ -88,12 +88,12 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // Empty branch path: stage and commit here, don't create a new branch. if branchIsEmpty { if err := stageAndValidate(cfg, opts); err != nil { - return nil + return ErrSilent } sha, err := doCommit(opts.message) if err != nil { cfg.Errorf("failed to commit: %s", err) - return nil + return ErrSilent } cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), currentBranch) cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch) @@ -115,7 +115,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, isFirstBranch) if name == "" { cfg.Errorf("could not generate branch name") - return nil + return ErrSilent } branchName = name if info != "" { @@ -136,7 +136,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if err != nil { if isInterruptError(err) { printInterrupt(cfg) - return nil + return ErrSilent } return fmt.Errorf("could not read branch name: %w", err) } @@ -152,36 +152,36 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if branchName == "" { cfg.Errorf("branch name cannot be empty") - return nil + return ErrSilent } if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { cfg.Errorf("branch %q already exists in the stack", branchName) - return nil + return ErrSilent } if git.BranchExists(branchName) { cfg.Errorf("branch %q already exists", branchName) - return nil + return ErrSilent } // Stage changes before creating the branch so we can fail early if // there's nothing to commit (avoids leaving an empty orphan branch). if wantsCommit { if err := stageAndValidate(cfg, opts); err != nil { - return nil + return ErrSilent } } // Create the new branch from the current HEAD and check it out if err := git.CreateBranch(branchName, currentBranch); err != nil { cfg.Errorf("failed to create branch: %s", err) - return nil + return ErrSilent } if err := git.CheckoutBranch(branchName); err != nil { cfg.Errorf("failed to checkout branch: %s", err) - return nil + return ErrSilent } base, err := git.RevParse(currentBranch) @@ -196,14 +196,14 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { sha, err := doCommit(opts.message) if err != nil { cfg.Errorf("failed to commit: %s", err) - return nil + return ErrSilent } commitSHA = sha } if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) - return nil + return ErrSilent } // Print summary diff --git a/cmd/checkout.go b/cmd/checkout.go index 37aa47c..bcbfa05 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -53,13 +53,13 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return nil + return ErrSilent } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return nil + return ErrSilent } var s *stack.Stack @@ -72,7 +72,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) } - return nil + return ErrSilent } if s == nil { return nil @@ -85,7 +85,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { s, br, err = resolvePR(sf, opts.target) if err != nil { cfg.Errorf("%s", err) - return nil + return ErrSilent } targetBranch = br.Branch } @@ -99,7 +99,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { if err := git.CheckoutBranch(targetBranch); err != nil { cfg.Errorf("failed to checkout %s: %v", targetBranch, err) - return nil + return ErrSilent } cfg.Successf("Switched to %s", targetBranch) diff --git a/cmd/init.go b/cmd/init.go index 9e278a4..b8c65e0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -52,7 +52,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return nil + return ErrSilent } // Determine trunk branch @@ -60,14 +60,14 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Enable git rerere so conflict resolutions are remembered. if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { - return nil + return ErrSilent } if trunk == "" { trunk, err = git.DefaultBranch() if err != nil { cfg.Errorf("unable to determine default branch\nUse -b to specify the trunk branch") - return nil + return ErrSilent } } @@ -75,7 +75,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return nil + return ErrSilent } // Set repository context @@ -93,7 +93,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { for _, s := range sf.FindAllStacksForBranch(currentBranch) { if s.IndexOf(currentBranch) >= 0 { cfg.Errorf("current branch %q is already part of a stack", currentBranch) - return nil + return ErrSilent } } } @@ -104,16 +104,16 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Adopt mode: validate all specified branches exist if len(opts.branches) == 0 { cfg.Errorf("--adopt requires at least one branch name") - return nil + return ErrSilent } for _, b := range opts.branches { if !git.BranchExists(b) { cfg.Errorf("branch %q does not exist", b) - return nil + return ErrSilent } if err := sf.ValidateNoDuplicateBranch(b); err != nil { cfg.Errorf("branch %q already exists in a stack", b) - return nil + return ErrSilent } } branches = opts.branches @@ -132,7 +132,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { state = "merged" } cfg.Errorf("branch %q already has a %s PR (#%d: %s)", b, state, pr.Number, pr.URL) - return nil + return ErrSilent } } } @@ -141,12 +141,12 @@ func runInit(cfg *config.Config, opts *initOptions) error { for _, b := range opts.branches { if err := sf.ValidateNoDuplicateBranch(b); err != nil { cfg.Errorf("branch %q already exists in a stack", b) - return nil + return ErrSilent } if !git.BranchExists(b) { if err := git.CreateBranch(b, trunk); err != nil { cfg.Errorf("creating branch %s: %s", b, err) - return nil + return ErrSilent } } } @@ -155,7 +155,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Interactive mode if !cfg.IsInteractive() { cfg.Errorf("interactive input required; provide branch names or use --adopt") - return nil + return ErrSilent } p := prompter.New(cfg.In, cfg.Out, cfg.Err) @@ -165,10 +165,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { if err != nil { if isInterruptError(err) { printInterrupt(cfg) - return nil + return ErrSilent } cfg.Errorf("failed to read prefix: %s", err) - return nil + return ErrSilent } opts.prefix = strings.TrimSpace(prefixInput) } @@ -183,15 +183,15 @@ func runInit(cfg *config.Config, opts *initOptions) error { if err != nil { if isInterruptError(err) { printInterrupt(cfg) - return nil + return ErrSilent } cfg.Errorf("failed to confirm branch selection: %s", err) - return nil + return ErrSilent } if useCurrentBranch { if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { cfg.Errorf("branch %q already exists in the stack", currentBranch) - return nil + return ErrSilent } branches = []string{currentBranch} } @@ -206,10 +206,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { if err != nil { if isInterruptError(err) { printInterrupt(cfg) - return nil + return ErrSilent } cfg.Errorf("failed to read branch name: %s", err) - return nil + return ErrSilent } branchName = strings.TrimSpace(branchName) @@ -218,7 +218,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { branchName = branch.NextNumberedName(opts.prefix, nil) } else if branchName == "" { cfg.Errorf("branch name cannot be empty") - return nil + return ErrSilent } else if opts.prefix != "" { // Prepend prefix to the user-provided name branchName = opts.prefix + "/" + branchName @@ -226,12 +226,12 @@ func runInit(cfg *config.Config, opts *initOptions) error { if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { cfg.Errorf("branch %q already exists in a stack", branchName) - return nil + return ErrSilent } if !git.BranchExists(branchName) { if err := git.CreateBranch(branchName, trunk); err != nil { cfg.Errorf("creating branch %s: %s", branchName, err) - return nil + return ErrSilent } } branches = []string{branchName} @@ -242,7 +242,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if opts.prefix != "" { if err := git.ValidateRefName(opts.prefix); err != nil { cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix) - return nil + return ErrSilent } } @@ -288,7 +288,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if currentBranch != lastBranch { if err := git.CheckoutBranch(lastBranch); err != nil { cfg.Errorf("switching to branch %s: %s", lastBranch, err) - return nil + return ErrSilent } cfg.Printf("Switched to branch %s", lastBranch) } else { diff --git a/cmd/merge.go b/cmd/merge.go index e924996..770821e 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -35,7 +35,7 @@ func runMerge(cfg *config.Config, target string) error { // Standard stack loading and validation. result, err := loadStack(cfg, "") if err != nil { - return nil + return ErrSilent } s := result.Stack currentBranch := result.CurrentBranch @@ -52,7 +52,7 @@ func runMerge(cfg *config.Config, target string) error { _, br, err = resolvePR(result.StackFile, target) if err != nil { cfg.Errorf("%s", err) - return nil + return ErrSilent } } else { idx := s.IndexOf(currentBranch) @@ -62,7 +62,7 @@ func runMerge(cfg *config.Config, target string) error { return nil } cfg.Errorf("current branch %q is not a stack branch (it may be the trunk)", currentBranch) - return nil + return ErrSilent } br = &s.Branches[idx] } @@ -70,7 +70,7 @@ func runMerge(cfg *config.Config, target string) error { if br.PullRequest == nil { cfg.Errorf("no pull request found for branch %q", currentBranch) cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack push")) - return nil + return ErrSilent } if br.IsMerged() { diff --git a/cmd/merge_test.go b/cmd/merge_test.go index 1c57e09..e57ee18 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -41,7 +41,7 @@ func TestMerge_NoPullRequest(t *testing.T) { errOut, _ := io.ReadAll(errR) output := string(errOut) - assert.NoError(t, err) + assert.ErrorIs(t, err, ErrSilent) assert.Contains(t, output, "no pull request found") assert.Contains(t, output, "gh stack push") } @@ -145,7 +145,7 @@ func TestMerge_OnTrunk(t *testing.T) { errOut, _ := io.ReadAll(errR) output := string(errOut) - assert.NoError(t, err) + assert.ErrorIs(t, err, ErrSilent) assert.Contains(t, output, "not a stack branch") } diff --git a/cmd/navigate.go b/cmd/navigate.go index c459a9d..ee5cd44 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -61,7 +61,7 @@ func BottomCmd(cfg *config.Config) *cobra.Command { func runNavigate(cfg *config.Config, delta int) error { result, err := loadStack(cfg, "") if err != nil { - return nil + return ErrSilent } s := result.Stack currentBranch := result.CurrentBranch @@ -175,14 +175,14 @@ func runNavigate(cfg *config.Config, delta int) error { func runNavigateToEnd(cfg *config.Config, top bool) error { result, err := loadStack(cfg, "") if err != nil { - return nil + return ErrSilent } s := result.Stack currentBranch := result.CurrentBranch if len(s.Branches) == 0 { cfg.Errorf("stack has no branches") - return nil + return ErrSilent } var targetIdx int diff --git a/cmd/push.go b/cmd/push.go index b62a522..131648b 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -40,19 +40,19 @@ func runPush(cfg *config.Config, opts *pushOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return nil + return ErrSilent } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return nil + return ErrSilent } currentBranch, err := git.CurrentBranch() if err != nil { cfg.Errorf("failed to get current branch: %s", err) - return nil + return ErrSilent } // Find the stack for the current branch without switching branches. @@ -60,18 +60,18 @@ func runPush(cfg *config.Config, opts *pushOptions) error { stacks := sf.FindAllStacksForBranch(currentBranch) if len(stacks) == 0 { cfg.Errorf("current branch %q is not part of a stack", currentBranch) - return nil + return ErrSilent } if len(stacks) > 1 { cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch) - return nil + return ErrSilent } s := stacks[0] client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) - return nil + return ErrSilent } // Push all active branches atomically @@ -80,7 +80,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) } - return nil + return ErrSilent } merged := s.MergedBranches() if len(merged) > 0 { @@ -90,7 +90,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote) if err := git.Push(remote, activeBranches, true, true); err != nil { cfg.Errorf("failed to push: %s", err) - return nil + return ErrSilent } if opts.skipPRs { @@ -123,7 +123,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if err != nil { if isInterruptError(err) { printInterrupt(cfg) - return nil + return ErrSilent } // Non-interrupt error: keep the auto-generated title. } else if input != "" { @@ -188,7 +188,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) - return nil + return ErrSilent } cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) diff --git a/cmd/rebase.go b/cmd/rebase.go index 76bbb3a..b96c605 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -69,7 +69,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return nil + return ErrSilent } if opts.cont { @@ -82,7 +82,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { result, err := loadStack(cfg, opts.branch) if err != nil { - return nil + return ErrSilent } sf := result.StackFile s := result.Stack @@ -90,7 +90,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { // Enable git rerere so conflict resolutions are remembered. if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { - return nil + return ErrSilent } // Resolve remote for fetch and trunk comparison @@ -99,7 +99,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) } - return nil + return ErrSilent } if err := git.Fetch(remote); err != nil { @@ -178,7 +178,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { originalRefs, err := git.RevParseMap(branchNames) if err != nil { cfg.Errorf("failed to resolve branch SHAs: %s", err) - return nil + return ErrSilent } // Track --onto rebase state for squash-merged branches. @@ -332,13 +332,13 @@ func continueRebase(cfg *config.Config, gitDir string) error { state, err := loadRebaseState(gitDir) if err != nil { cfg.Errorf("no rebase in progress") - return nil + return ErrSilent } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return nil + return ErrSilent } // Use the saved original branch to find the stack, since git may be in @@ -453,7 +453,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { } else { if err := git.CheckoutBranch(branchName); err != nil { cfg.Errorf("checking out %s: %s", branchName, err) - return nil + return ErrSilent } rebaseErr = git.Rebase(base) } @@ -507,7 +507,7 @@ func abortRebase(cfg *config.Config, gitDir string) error { state, err := loadRebaseState(gitDir) if err != nil { cfg.Errorf("no rebase in progress") - return nil + return ErrSilent } if git.IsRebaseInProgress() { @@ -533,7 +533,7 @@ func abortRebase(cfg *config.Config, gitDir string) error { for _, e := range restoreErrors { cfg.Printf(" %s", e) } - return nil + return ErrSilent } cfg.Successf("Rebase aborted and branches restored") diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 9a9c0a3..0006aab 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -287,7 +287,7 @@ func TestRebase_Continue_NoState(t *testing.T) { errOut, _ := io.ReadAll(errR) output := string(errOut) - assert.NoError(t, err, "should return nil (error printed via cfg.Errorf)") + assert.ErrorIs(t, err, ErrSilent) assert.Contains(t, output, "no rebase in progress") } diff --git a/cmd/root.go b/cmd/root.go index ae175a8..509ae13 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -53,7 +54,9 @@ func RootCmd() *cobra.Command { func Execute() { cmd := RootCmd() if err := cmd.Execute(); err != nil { - fmt.Fprintln(cmd.ErrOrStderr(), err) + if !errors.Is(err, ErrSilent) { + fmt.Fprintln(cmd.ErrOrStderr(), err) + } os.Exit(1) } } diff --git a/cmd/sync.go b/cmd/sync.go index 883457a..2b265b9 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -43,7 +43,7 @@ conflicts interactively.`, func runSync(cfg *config.Config, _ *syncOptions) error { result, err := loadStack(cfg, "") if err != nil { - return nil + return ErrSilent } gitDir := result.GitDir sf := result.StackFile @@ -56,13 +56,13 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) } - return nil + return ErrSilent } // --- Step 1: Fetch --- // Enable git rerere so conflict resolutions are remembered. if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { - return nil + return ErrSilent } if err := git.Fetch(remote); err != nil { @@ -280,7 +280,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) - return nil + return ErrSilent } cfg.Printf("") diff --git a/cmd/unstack.go b/cmd/unstack.go index 2f6acc2..7ebda3b 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -35,7 +35,7 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { func runUnstack(cfg *config.Config, opts *unstackOptions) error { result, err := loadStack(cfg, opts.target) if err != nil { - return nil + return ErrSilent } gitDir := result.GitDir sf := result.StackFile @@ -51,7 +51,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { sf.RemoveStackForBranch(target) if err := stack.Save(gitDir, sf); err != nil { cfg.Errorf("failed to save stack state: %s", err) - return nil + return ErrSilent } cfg.Successf("Stack removed from local tracking") @@ -60,7 +60,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) - return nil + return ErrSilent } if err := client.DeleteStack(); err != nil { cfg.Warningf("%v", err) diff --git a/cmd/utils.go b/cmd/utils.go index f180909..089db43 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -14,6 +14,10 @@ import ( "github.com/github/gh-stack/internal/stack" ) +// ErrSilent indicates the error has already been printed to the user. +// Execute() will exit with code 1 but will not print the error again. +var ErrSilent = errors.New("silent error") + // errInterrupt is a sentinel returned when a prompt is cancelled via Ctrl+C. // Callers should exit silently (the friendly message is already printed). var errInterrupt = errors.New("interrupt") diff --git a/cmd/view.go b/cmd/view.go index a5697e9..5dfce60 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -42,7 +42,7 @@ func ViewCmd(cfg *config.Config) *cobra.Command { func runView(cfg *config.Config, opts *viewOptions) error { result, err := loadStack(cfg, "") if err != nil { - return nil + return ErrSilent } gitDir := result.GitDir sf := result.StackFile From 6e9927d1249eafd98325c1f88e437db1b61ac291 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 23 Mar 2026 04:46:55 -0400 Subject: [PATCH 64/78] rm update pr base logic, should all happen on server --- cmd/push.go | 11 +---------- internal/github/client_interface.go | 1 - internal/github/github.go | 25 ------------------------- internal/github/mock_client.go | 12 ++---------- 4 files changed, 3 insertions(+), 46 deletions(-) diff --git a/cmd/push.go b/cmd/push.go index 131648b..0de29a6 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -152,16 +152,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { URL: newPR.URL, } } else { - // Update base if needed - if pr.BaseRefName != baseBranch { - if err := client.UpdatePRBase(pr.ID, baseBranch); err != nil { - cfg.Warningf("failed to update PR %s base: %v", cfg.PRLink(pr.Number, pr.URL), err) - } else { - cfg.Successf("Updated PR %s base to %s", cfg.PRLink(pr.Number, pr.URL), baseBranch) - } - } else { - cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) - } + cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) if s.Branches[i].PullRequest == nil { s.Branches[i].PullRequest = &stack.PullRequestRef{ Number: pr.Number, diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go index 5d5c675..99af071 100644 --- a/internal/github/client_interface.go +++ b/internal/github/client_interface.go @@ -8,7 +8,6 @@ type ClientOps interface { FindAnyPRForBranch(branch string) (*PullRequest, error) FindPRDetailsForBranch(branch string) (*PRDetails, error) CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) - UpdatePRBase(prID, newBase string) error DeleteStack() error } diff --git a/internal/github/github.go b/internal/github/github.go index c54efe2..6cd8861 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -185,31 +185,6 @@ func (c *Client) CreatePR(base, head, title, body string, draft bool) (*PullRequ }, nil } -// UpdatePRBase updates the base branch of a pull request. -func (c *Client) UpdatePRBase(prID, newBase string) error { - var mutation struct { - UpdatePullRequest struct { - PullRequest struct { - ID string - } - } `graphql:"updatePullRequest(input: $input)"` - } - - type UpdatePullRequestInput struct { - PullRequestID string `json:"pullRequestId"` - BaseRefName string `json:"baseRefName"` - } - - variables := map[string]interface{}{ - "input": UpdatePullRequestInput{ - PullRequestID: prID, - BaseRefName: newBase, - }, - } - - return c.gql.Mutate("UpdatePullRequest", &mutation, variables) -} - // PRDetails holds enriched pull request data for display in the TUI. type PRDetails struct { Number int diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go index 2389682..5b59177 100644 --- a/internal/github/mock_client.go +++ b/internal/github/mock_client.go @@ -9,9 +9,8 @@ type MockClient struct { FindPRForBranchFn func(string) (*PullRequest, error) FindAnyPRForBranchFn func(string) (*PullRequest, error) FindPRDetailsForBranchFn func(string) (*PRDetails, error) - CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) - UpdatePRBaseFn func(string, string) error - DeleteStackFn func() error + CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) + DeleteStackFn func() error } // Compile-time check that MockClient satisfies ClientOps. @@ -45,13 +44,6 @@ func (m *MockClient) CreatePR(base, head, title, body string, draft bool) (*Pull return nil, nil } -func (m *MockClient) UpdatePRBase(prID, newBase string) error { - if m.UpdatePRBaseFn != nil { - return m.UpdatePRBaseFn(prID, newBase) - } - return nil -} - func (m *MockClient) DeleteStack() error { if m.DeleteStackFn != nil { return m.DeleteStackFn() From 260497643ae56df637436185d5aaed82b7e9b50a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 00:38:04 -0400 Subject: [PATCH 65/78] exit codes by error type --- README.md | 13 +++++++++++++ cmd/add.go | 12 ++++++------ cmd/checkout.go | 6 +++--- cmd/init.go | 28 ++++++++++++++-------------- cmd/merge.go | 6 +++--- cmd/merge_test.go | 2 +- cmd/navigate.go | 6 +++--- cmd/push.go | 12 ++++++------ cmd/rebase.go | 14 +++++++------- cmd/rebase_test.go | 2 +- cmd/root.go | 6 ++++-- cmd/sync.go | 2 +- cmd/unstack.go | 4 ++-- cmd/utils.go | 30 +++++++++++++++++++++++++++++- cmd/view.go | 2 +- 15 files changed, 94 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index c84ad1d..c21bf48 100644 --- a/README.md +++ b/README.md @@ -427,3 +427,16 @@ gh stack push ``` Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Generic error | +| 2 | Not in a stack / stack not found | +| 3 | Rebase conflict | +| 4 | GitHub API failure | +| 5 | Invalid arguments or flags | +| 6 | Disambiguation required (branch belongs to multiple stacks) | +| 7 | Rebase already in progress | diff --git a/cmd/add.go b/cmd/add.go index 261fe5c..89d5d3a 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -46,12 +46,12 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // Validate flag combinations if opts.stageAll && opts.stageTracked { cfg.Errorf("flags -A and -u are mutually exclusive") - return ErrSilent + return ErrInvalidArgs } result, err := loadStack(cfg, "") if err != nil { - return ErrSilent + return ErrNotInStack } gitDir := result.GitDir sf := result.StackFile @@ -69,7 +69,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // a new branch from it). Only block if we're in the middle of the stack. if idx >= 0 && idx < len(s.Branches)-1 { cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch) - return ErrSilent + return ErrInvalidArgs } // Check if the current branch is a stack branch with no unique commits @@ -152,17 +152,17 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if branchName == "" { cfg.Errorf("branch name cannot be empty") - return ErrSilent + return ErrInvalidArgs } if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { cfg.Errorf("branch %q already exists in the stack", branchName) - return ErrSilent + return ErrInvalidArgs } if git.BranchExists(branchName) { cfg.Errorf("branch %q already exists", branchName) - return ErrSilent + return ErrInvalidArgs } // Stage changes before creating the branch so we can fail early if diff --git a/cmd/checkout.go b/cmd/checkout.go index bcbfa05..a32e1d8 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -53,13 +53,13 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return ErrSilent + return ErrNotInStack } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return ErrSilent + return ErrNotInStack } var s *stack.Stack @@ -85,7 +85,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { s, br, err = resolvePR(sf, opts.target) if err != nil { cfg.Errorf("%s", err) - return ErrSilent + return ErrNotInStack } targetBranch = br.Branch } diff --git a/cmd/init.go b/cmd/init.go index b8c65e0..337178a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -52,7 +52,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return ErrSilent + return ErrNotInStack } // Determine trunk branch @@ -67,7 +67,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { trunk, err = git.DefaultBranch() if err != nil { cfg.Errorf("unable to determine default branch\nUse -b to specify the trunk branch") - return ErrSilent + return ErrNotInStack } } @@ -75,7 +75,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return ErrSilent + return ErrNotInStack } // Set repository context @@ -93,7 +93,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { for _, s := range sf.FindAllStacksForBranch(currentBranch) { if s.IndexOf(currentBranch) >= 0 { cfg.Errorf("current branch %q is already part of a stack", currentBranch) - return ErrSilent + return ErrInvalidArgs } } } @@ -104,16 +104,16 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Adopt mode: validate all specified branches exist if len(opts.branches) == 0 { cfg.Errorf("--adopt requires at least one branch name") - return ErrSilent + return ErrInvalidArgs } for _, b := range opts.branches { if !git.BranchExists(b) { cfg.Errorf("branch %q does not exist", b) - return ErrSilent + return ErrInvalidArgs } if err := sf.ValidateNoDuplicateBranch(b); err != nil { cfg.Errorf("branch %q already exists in a stack", b) - return ErrSilent + return ErrInvalidArgs } } branches = opts.branches @@ -132,7 +132,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { state = "merged" } cfg.Errorf("branch %q already has a %s PR (#%d: %s)", b, state, pr.Number, pr.URL) - return ErrSilent + return ErrInvalidArgs } } } @@ -141,7 +141,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { for _, b := range opts.branches { if err := sf.ValidateNoDuplicateBranch(b); err != nil { cfg.Errorf("branch %q already exists in a stack", b) - return ErrSilent + return ErrInvalidArgs } if !git.BranchExists(b) { if err := git.CreateBranch(b, trunk); err != nil { @@ -155,7 +155,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Interactive mode if !cfg.IsInteractive() { cfg.Errorf("interactive input required; provide branch names or use --adopt") - return ErrSilent + return ErrInvalidArgs } p := prompter.New(cfg.In, cfg.Out, cfg.Err) @@ -191,7 +191,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if useCurrentBranch { if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { cfg.Errorf("branch %q already exists in the stack", currentBranch) - return ErrSilent + return ErrInvalidArgs } branches = []string{currentBranch} } @@ -218,7 +218,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { branchName = branch.NextNumberedName(opts.prefix, nil) } else if branchName == "" { cfg.Errorf("branch name cannot be empty") - return ErrSilent + return ErrInvalidArgs } else if opts.prefix != "" { // Prepend prefix to the user-provided name branchName = opts.prefix + "/" + branchName @@ -226,7 +226,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { cfg.Errorf("branch %q already exists in a stack", branchName) - return ErrSilent + return ErrInvalidArgs } if !git.BranchExists(branchName) { if err := git.CreateBranch(branchName, trunk); err != nil { @@ -242,7 +242,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if opts.prefix != "" { if err := git.ValidateRefName(opts.prefix); err != nil { cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix) - return ErrSilent + return ErrInvalidArgs } } diff --git a/cmd/merge.go b/cmd/merge.go index 770821e..bb9859e 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -35,7 +35,7 @@ func runMerge(cfg *config.Config, target string) error { // Standard stack loading and validation. result, err := loadStack(cfg, "") if err != nil { - return ErrSilent + return ErrNotInStack } s := result.Stack currentBranch := result.CurrentBranch @@ -52,7 +52,7 @@ func runMerge(cfg *config.Config, target string) error { _, br, err = resolvePR(result.StackFile, target) if err != nil { cfg.Errorf("%s", err) - return ErrSilent + return ErrNotInStack } } else { idx := s.IndexOf(currentBranch) @@ -62,7 +62,7 @@ func runMerge(cfg *config.Config, target string) error { return nil } cfg.Errorf("current branch %q is not a stack branch (it may be the trunk)", currentBranch) - return ErrSilent + return ErrNotInStack } br = &s.Branches[idx] } diff --git a/cmd/merge_test.go b/cmd/merge_test.go index e57ee18..5b154ee 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -145,7 +145,7 @@ func TestMerge_OnTrunk(t *testing.T) { errOut, _ := io.ReadAll(errR) output := string(errOut) - assert.ErrorIs(t, err, ErrSilent) + assert.ErrorIs(t, err, ErrNotInStack) assert.Contains(t, output, "not a stack branch") } diff --git a/cmd/navigate.go b/cmd/navigate.go index ee5cd44..211184c 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -61,7 +61,7 @@ func BottomCmd(cfg *config.Config) *cobra.Command { func runNavigate(cfg *config.Config, delta int) error { result, err := loadStack(cfg, "") if err != nil { - return ErrSilent + return ErrNotInStack } s := result.Stack currentBranch := result.CurrentBranch @@ -175,14 +175,14 @@ func runNavigate(cfg *config.Config, delta int) error { func runNavigateToEnd(cfg *config.Config, top bool) error { result, err := loadStack(cfg, "") if err != nil { - return ErrSilent + return ErrNotInStack } s := result.Stack currentBranch := result.CurrentBranch if len(s.Branches) == 0 { cfg.Errorf("stack has no branches") - return ErrSilent + return ErrNotInStack } var targetIdx int diff --git a/cmd/push.go b/cmd/push.go index 0de29a6..37f376d 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -40,19 +40,19 @@ func runPush(cfg *config.Config, opts *pushOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return ErrSilent + return ErrNotInStack } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return ErrSilent + return ErrNotInStack } currentBranch, err := git.CurrentBranch() if err != nil { cfg.Errorf("failed to get current branch: %s", err) - return ErrSilent + return ErrNotInStack } // Find the stack for the current branch without switching branches. @@ -60,18 +60,18 @@ func runPush(cfg *config.Config, opts *pushOptions) error { stacks := sf.FindAllStacksForBranch(currentBranch) if len(stacks) == 0 { cfg.Errorf("current branch %q is not part of a stack", currentBranch) - return ErrSilent + return ErrNotInStack } if len(stacks) > 1 { cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch) - return ErrSilent + return ErrDisambiguate } s := stacks[0] client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) - return ErrSilent + return ErrAPIFailure } // Push all active branches atomically diff --git a/cmd/rebase.go b/cmd/rebase.go index b96c605..dc39ff0 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -69,7 +69,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") - return ErrSilent + return ErrNotInStack } if opts.cont { @@ -82,7 +82,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { result, err := loadStack(cfg, opts.branch) if err != nil { - return ErrSilent + return ErrNotInStack } sf := result.StackFile s := result.Stack @@ -242,7 +242,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { br.Branch, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) - return fmt.Errorf("rebase conflict on %s", br.Branch) + return ErrConflict } cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) @@ -290,7 +290,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { br.Branch, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) - return fmt.Errorf("rebase conflict on %s", br.Branch) + return ErrConflict } cfg.Successf("Rebased %s onto %s", br.Branch, base) @@ -338,7 +338,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return ErrSilent + return ErrNotInStack } // Use the saved original branch to find the stack, since git may be in @@ -440,7 +440,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { branchName, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) - return fmt.Errorf("rebase conflict on %s", branchName) + return ErrConflict } cfg.Successf("Rebased %s onto %s (squash-merge detected)", branchName, newBase) @@ -480,7 +480,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { branchName, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) - return fmt.Errorf("rebase conflict on %s", branchName) + return ErrConflict } cfg.Successf("Rebased %s onto %s", branchName, base) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 0006aab..2288b35 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -250,7 +250,7 @@ func TestRebase_ConflictSavesState(t *testing.T) { output := string(errOut) assert.Error(t, err) - assert.Contains(t, err.Error(), "rebase conflict on b2") + assert.ErrorIs(t, err, ErrConflict) assert.Contains(t, output, "--continue") // Verify state file was saved diff --git a/cmd/root.go b/cmd/root.go index 509ae13..0cd60b7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -54,9 +54,11 @@ func RootCmd() *cobra.Command { func Execute() { cmd := RootCmd() if err := cmd.Execute(); err != nil { - if !errors.Is(err, ErrSilent) { - fmt.Fprintln(cmd.ErrOrStderr(), err) + var exitErr *ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.Code) } + fmt.Fprintln(cmd.ErrOrStderr(), err) os.Exit(1) } } diff --git a/cmd/sync.go b/cmd/sync.go index 2b265b9..af39a86 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -43,7 +43,7 @@ conflicts interactively.`, func runSync(cfg *config.Config, _ *syncOptions) error { result, err := loadStack(cfg, "") if err != nil { - return ErrSilent + return ErrNotInStack } gitDir := result.GitDir sf := result.StackFile diff --git a/cmd/unstack.go b/cmd/unstack.go index 7ebda3b..a8c6a12 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -35,7 +35,7 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { func runUnstack(cfg *config.Config, opts *unstackOptions) error { result, err := loadStack(cfg, opts.target) if err != nil { - return ErrSilent + return ErrNotInStack } gitDir := result.GitDir sf := result.StackFile @@ -60,7 +60,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) - return ErrSilent + return ErrAPIFailure } if err := client.DeleteStack(); err != nil { cfg.Warningf("%v", err) diff --git a/cmd/utils.go b/cmd/utils.go index 089db43..df0a407 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -16,7 +16,35 @@ import ( // ErrSilent indicates the error has already been printed to the user. // Execute() will exit with code 1 but will not print the error again. -var ErrSilent = errors.New("silent error") +var ErrSilent = &ExitError{Code: 1} + +// Typed exit errors for programmatic detection by scripts and agents. +var ( + ErrNotInStack = &ExitError{Code: 2} // branch/stack not found + ErrConflict = &ExitError{Code: 3} // rebase conflict + ErrAPIFailure = &ExitError{Code: 4} // GitHub API error + ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags + ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select + ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress +) + +// ExitError is returned by commands to indicate a specific exit code. +// Execute() extracts the code and passes it to os.Exit. +type ExitError struct { + Code int +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("exit status %d", e.Code) +} + +func (e *ExitError) Is(target error) bool { + t, ok := target.(*ExitError) + if !ok { + return false + } + return e.Code == t.Code +} // errInterrupt is a sentinel returned when a prompt is cancelled via Ctrl+C. // Callers should exit silently (the friendly message is already printed). diff --git a/cmd/view.go b/cmd/view.go index 5dfce60..76a3cf5 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -42,7 +42,7 @@ func ViewCmd(cfg *config.Config) *cobra.Command { func runView(cfg *config.Config, opts *viewOptions) error { result, err := loadStack(cfg, "") if err != nil { - return ErrSilent + return ErrNotInStack } gitDir := result.GitDir sf := result.StackFile From d6e6db7a4581851e842ddab2a37abce3b7156a30 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 01:13:10 -0400 Subject: [PATCH 66/78] json output mode for view --- README.md | 4 +- cmd/view.go | 132 ++++++++++++++++++++--------------- cmd/view_test.go | 174 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index c21bf48..e7646a9 100644 --- a/README.md +++ b/README.md @@ -265,14 +265,14 @@ Shows all branches in the stack, their ordering, PR links, and the most recent c | Flag | Description | |------|-------------| | `-s, --short` | Compact output (branch names only) | -| `-w, --web` | Open all associated PRs in the browser | +| `--json` | Output stack data as JSON | **Examples:** ```sh gh stack view gh stack view --short -gh stack view --web +gh stack view --json ``` ### `gh stack unstack` diff --git a/cmd/view.go b/cmd/view.go index 76a3cf5..1aa22b3 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "fmt" "os" "os/exec" @@ -9,7 +10,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/cli/go-gh/v2/pkg/browser" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" @@ -18,8 +18,8 @@ import ( ) type viewOptions struct { - short bool - web bool + short bool + asJSON bool } func ViewCmd(cfg *config.Config) *cobra.Command { @@ -34,7 +34,7 @@ func ViewCmd(cfg *config.Config) *cobra.Command { } cmd.Flags().BoolVarP(&opts.short, "short", "s", false, "Show compact output") - cmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open PRs in the browser") + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output stack data as JSON") return cmd } @@ -53,8 +53,8 @@ func runView(cfg *config.Config, opts *viewOptions) error { syncStackPRs(cfg, s) _ = stack.Save(gitDir, sf) - if opts.web { - return viewWeb(cfg, s) + if opts.asJSON { + return viewJSON(cfg, s, currentBranch) } if opts.short { @@ -115,6 +115,78 @@ func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef return "" } +// JSON output types for gh stack view --json. +type viewJSONOutput struct { + Trunk string `json:"trunk"` + Prefix string `json:"prefix,omitempty"` + CurrentBranch string `json:"currentBranch"` + Branches []viewJSONBranch `json:"branches"` +} + +type viewJSONBranch struct { + Name string `json:"name"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + IsCurrent bool `json:"isCurrent"` + IsMerged bool `json:"isMerged"` + NeedsRebase bool `json:"needsRebase"` + PR *viewJSONPR `json:"pr,omitempty"` +} + +type viewJSONPR struct { + Number int `json:"number"` + URL string `json:"url,omitempty"` + State string `json:"state"` +} + +func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error { + out := viewJSONOutput{ + Trunk: s.Trunk.Branch, + Prefix: s.Prefix, + CurrentBranch: currentBranch, + Branches: make([]viewJSONBranch, 0, len(s.Branches)), + } + + for _, b := range s.Branches { + jb := viewJSONBranch{ + Name: b.Branch, + Head: b.Head, + Base: b.Base, + IsCurrent: b.Branch == currentBranch, + IsMerged: b.IsMerged(), + } + + // Check if the branch needs rebasing (base not ancestor of branch). + if !jb.IsMerged { + baseBranch := s.ActiveBaseBranch(b.Branch) + if isAnc, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !isAnc { + jb.NeedsRebase = true + } + } + + if b.PullRequest != nil && b.PullRequest.Number != 0 { + state := "OPEN" + if b.PullRequest.Merged { + state = "MERGED" + } + jb.PR = &viewJSONPR{ + Number: b.PullRequest.Number, + URL: b.PullRequest.URL, + State: state, + } + } + + out.Branches = append(out.Branches, jb) + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("marshalling JSON: %w", err) + } + _, err = fmt.Fprintf(cfg.Out, "%s\n", data) + return err +} + func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) string { if b.PullRequest == nil || b.PullRequest.Number == 0 { return "" @@ -318,51 +390,3 @@ func timeAgo(t time.Time) string { return fmt.Sprintf("%d months ago", months) } } - -func viewWeb(cfg *config.Config, s *stack.Stack) error { - client, err := cfg.GitHubClient() - if err != nil { - return err - } - - repo, err := cfg.Repo() - if err != nil { - return err - } - - b := browser.New("", cfg.Out, cfg.Err) - - opened := 0 - for _, br := range s.Branches { - if br.IsMerged() { - continue - } - var url string - if br.PullRequest != nil && br.PullRequest.URL != "" { - url = br.PullRequest.URL - } else { - pr, err := client.FindPRForBranch(br.Branch) - if err != nil || pr == nil { - continue - } - url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number) - } - if err := b.Browse(url); err != nil { - cfg.Warningf("failed to open %s: %v", url, err) - } else { - opened++ - } - } - - if opened == 0 { - cfg.Printf("No PRs found to open in browser.") - } else { - cfg.Successf("Opened %d PRs in browser", opened) - } - - if mergedCount := len(s.MergedBranches()); mergedCount > 0 { - cfg.Printf("Skipped %d merged PRs", mergedCount) - } - - return nil -} diff --git a/cmd/view_test.go b/cmd/view_test.go index 9407068..efe9f54 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -1,10 +1,16 @@ package cmd import ( + "encoding/json" + "io" "testing" "time" + "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" + "github.com/stretchr/testify/require" ) func TestTimeAgo(t *testing.T) { @@ -30,3 +36,171 @@ func TestTimeAgo(t *testing.T) { }) } } + +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{ + Prefix: "feat", + 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{ + Prefix: "feat", + 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)) + + assert.Equal(t, "feat", got.Prefix) + + // 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) +} From 13046702244b3f40252bfde65a140d3a27ed06b4 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 01:36:05 -0400 Subject: [PATCH 67/78] flag to specify remote and bypass interactive prompt --- README.md | 8 +++++++- cmd/push.go | 13 ++++++++++--- cmd/rebase.go | 4 +++- cmd/sync.go | 10 +++++++--- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e7646a9..a90f3d9 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file | `--upstack` | Only rebase branches from the current branch to the top | | `--continue` | Continue the rebase after resolving conflicts | | `--abort` | Abort the rebase and restore all branches to their pre-rebase state | +| `--remote ` | Remote to fetch from (defaults to auto-detected remote) | | Argument | Description | |----------|-------------| @@ -208,7 +209,7 @@ gh stack rebase --abort Fetch, rebase, push, and sync PR state in a single command. ``` -gh stack sync +gh stack sync [flags] ``` Performs a safe, non-interactive synchronization of the entire stack: @@ -219,6 +220,10 @@ Performs a safe, non-interactive synchronization of the entire stack: 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred) 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR +| Flag | Description | +|------|-------------| +| `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | + **Examples:** ```sh @@ -242,6 +247,7 @@ When creating new PRs, you will be prompted to enter a title for each one. Press | `--auto` | Use auto-generated PR titles without prompting | | `--draft` | Create new PRs as drafts | | `--skip-prs` | Push branches without creating or updating PRs | +| `--remote ` | Remote to push to (defaults to auto-detected remote) | **Examples:** diff --git a/cmd/push.go b/cmd/push.go index 37f376d..eb08f46 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -16,6 +16,7 @@ type pushOptions struct { auto bool draft bool skipPRs bool + remote string } func PushCmd(cfg *config.Config) *cobra.Command { @@ -32,6 +33,7 @@ func PushCmd(cfg *config.Config) *cobra.Command { cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting") cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") cmd.Flags().BoolVar(&opts.skipPRs, "skip-prs", false, "Push branches without creating or updating PRs") + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)") return cmd } @@ -75,7 +77,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } // Push all active branches atomically - remote, err := pickRemote(cfg, currentBranch) + remote, err := pickRemote(cfg, currentBranch, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) @@ -225,11 +227,16 @@ func humanize(s string) string { }, s) } -// pickRemote determines which remote to push to. It delegates to +// pickRemote determines which remote to push to. If remoteOverride is +// non-empty, it is returned directly. Otherwise it delegates to // git.ResolveRemote for config-based resolution and remote listing. // If multiple remotes exist with no configured default, the user is // prompted to select one interactively. -func pickRemote(cfg *config.Config, branch string) (string, error) { +func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) { + if remoteOverride != "" { + return remoteOverride, nil + } + remote, err := git.ResolveRemote(branch) if err == nil { return remote, nil diff --git a/cmd/rebase.go b/cmd/rebase.go index dc39ff0..406f78d 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -20,6 +20,7 @@ type rebaseOptions struct { upstack bool cont bool abort bool + remote string } type rebaseState struct { @@ -61,6 +62,7 @@ layer in its commit history, rebasing if necessary.`, cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only rebase branches from current branch to top") cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts") cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches") + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from (defaults to auto-detected remote)") return cmd } @@ -94,7 +96,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } // Resolve remote for fetch and trunk comparison - remote, err := pickRemote(cfg, currentBranch) + remote, err := pickRemote(cfg, currentBranch, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) diff --git a/cmd/sync.go b/cmd/sync.go index af39a86..5e8cd0b 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -11,7 +11,9 @@ import ( "github.com/spf13/cobra" ) -type syncOptions struct{} +type syncOptions struct { + remote string +} func SyncCmd(cfg *config.Config) *cobra.Command { opts := &syncOptions{} @@ -37,10 +39,12 @@ conflicts interactively.`, }, } + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)") + return cmd } -func runSync(cfg *config.Config, _ *syncOptions) error { +func runSync(cfg *config.Config, opts *syncOptions) error { result, err := loadStack(cfg, "") if err != nil { return ErrNotInStack @@ -51,7 +55,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error { currentBranch := result.CurrentBranch // Resolve remote once for fetch and push - remote, err := pickRemote(cfg, currentBranch) + remote, err := pickRemote(cfg, currentBranch, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) From 309e12a8aba7df26a4ddf3aec9fb0045e98cca78 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 11:08:35 -0400 Subject: [PATCH 68/78] skills file for agents --- README.md | 20 +- cmd/init.go | 1 - skills/gh-stack/SKILL.md | 598 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 skills/gh-stack/SKILL.md diff --git a/README.md b/README.md index a90f3d9..5245859 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,14 @@ gh extension install github/gh-stack Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+. +## AI agent integration + +Install the gh-stack skill so your AI coding agent knows how to work with stacked PRs and the `gh stack` CLI: + +```sh +npx skills add github/gh-stack +``` + ## Quick start ```sh @@ -35,15 +43,19 @@ gh stack view ## How it works -A **stack** is an ordered list of branches where each branch builds on the one below it. The bottom of the stack is based on a **trunk** branch (typically `main`). +A **stack** is an ordered list of branches where each branch builds on the one below it. The **bottom** of the stack is based on a **trunk** branch (typically `main`). ``` +frontend → PR #3 (base: api-endpoints) ← top +api-endpoints → PR #2 (base: auth-layer) +auth-layer → PR #1 (base: main) ← bottom +───────────── main (trunk) - └── auth-layer → PR #1 (base: main) - └── api-endpoints → PR #2 (base: auth-layer) ``` -When you push, `gh stack` creates one PR per branch. Each PR's base is set to the branch below it in the stack (**branch-chaining**), so reviewers see only the diff for that layer. +The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from it. Each branch inherits from the one below it. Navigation commands (`up`, `down`, `top`, `bottom`) follow this model: `up` moves away from trunk, `down` moves toward it. + +When you push, `gh stack` creates one PR per branch. Each PR's base is set to the branch below it in the stack, so reviewers see only the diff for that layer. ### Local tracking diff --git a/cmd/init.go b/cmd/init.go index 337178a..cf6926a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -28,7 +28,6 @@ func InitCmd(cfg *config.Config) *cobra.Command { Short: "Initialize a new stack", Long: `Initialize a stack object in the local repo. -Creates an entry in .git/gh-stack to track stack state. Unless specified, prompts user to create/select branch for first layer of the stack. Trunk defaults to default branch, unless specified otherwise.`, Example: ` $ gh stack init diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md new file mode 100644 index 0000000..7dcc4b4 --- /dev/null +++ b/skills/gh-stack/SKILL.md @@ -0,0 +1,598 @@ +--- +name: gh-stack +description: > + Manage stacked branches and pull requests with the gh-stack GitHub CLI extension. + Use when the user wants to create, push, rebase, sync, navigate, or view stacks of + dependent PRs. Triggers on tasks involving stacked diffs, dependent pull requests, + branch chains, or incremental code review workflows. +metadata: + author: github + version: "0.0.1" +--- + +# gh-stack + +`gh stack` is a [GitHub CLI](https://cli.github.com/) extension for managing **stacked branches and pull requests**. A stack is an ordered list of branches where each branch builds on the one below it, rooted on a trunk branch (typically the repo's default branch). Each branch maps to one PR whose base is the branch below it, so reviewers see only the diff for that layer. + +``` +main (trunk) + └── auth-layer → PR #1 (base: main) - bottom (closest to trunk) + └── api-endpoints → PR #2 (base: auth-layer) + └── frontend → PR #3 (base: api-endpoints) - top (furthest from trunk) +``` + +The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from the trunk. Each branch inherits from the one below it. Navigation commands (`up`, `down`, `top`, `bottom`) follow this model: `up` moves away from trunk, `down` moves toward it. + +## When to use this skill + +Use this skill when the user wants to: + +- Break a large change into a chain of small, reviewable PRs +- Create, rebase, push, or sync a stack of dependent branches +- Navigate between layers of a branch stack +- View the status of stacked PRs +- Clean up a stack after PRs are merged + +## Prerequisites + +The GitHub CLI (`gh`) v2.0+ must be installed and authenticated. Install the extension with: + +```bash +gh extension install github/gh-stack +``` + +## Agent rules + +1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. +2. **Always use `--auto` when pushing** to skip PR title prompts. +3. **Always use `--json` when viewing** to get structured output. +4. **Use `--remote ` when multiple remotes are configured**, or set `remote.pushDefault` in git config. +5. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. + +## Quick reference + +| Task | Command | +|------|---------| +| Create a stack | `gh stack init -p feat auth` | +| Create a stack with explicit branch names (no prefix) | `gh stack init branch-a` | +| Adopt existing branches | `gh stack init --adopt branch-a branch-b` | +| Set custom trunk | `gh stack init --base develop branch-a` | +| Add a branch to stack | `gh stack add branch-name` | +| Add branch + stage all + commit | `gh stack add -Am "message" new-branch` | +| Add branch + stage tracked + commit | `gh stack add -um "message" new-branch` | +| Push + create PRs | `gh stack push --auto` | +| Push as drafts | `gh stack push --auto --draft` | +| Push without creating PRs | `gh stack push --skip-prs` | +| Push to specific remote | `gh stack push --auto --remote origin` | +| Sync (fetch, rebase, push) | `gh stack sync` | +| Sync with specific remote | `gh stack sync --remote origin` | +| Rebase entire stack | `gh stack rebase` | +| Continue after conflict | `gh stack rebase --continue` | +| Abort rebase | `gh stack rebase --abort` | +| View stack details (JSON) | `gh stack view --json` | +| Switch branches up/down in stack | `gh stack up [n]` / `gh stack down [n]` | +| Switch to top/bottom branch | `gh stack top` / `gh stack bottom` | +| Check out by PR | `gh stack checkout 42` | +| Check out by branch | `gh stack checkout feature-auth` | +| Remove stack | `gh stack unstack --local` | + +--- + +## Workflows + +### End-to-end: create a stack from scratch + +```bash +# 1. Initialize a stack with a prefix and first branch (feat/auth) +gh stack init -p feat auth + +# 2. Write code and commit on the first branch +cat > auth.go << 'EOF' +package auth + +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // verify token + next.ServeHTTP(w, r) + }) +} +EOF +gh stack add -Am "Add auth middleware" +# → commits on feat/auth (no commits yet, so stays on current branch) + +# 3. Write more code and create the next layer +cat > api.go << 'EOF' +package api + +func RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/users", handleUsers) +} +EOF +gh stack add -Am "Add API routes" api-routes +# → creates feat/api-routes, checks it out, commits there + +# 4. Add a third layer +cat > api_test.go << 'EOF' +package api + +func TestRegisterRoutes(t *testing.T) { + // test routes +} +EOF +gh stack add -Am "Add API tests" api-tests +# → creates feat/api-tests, checks it out, commits there + +# 5. Push everything and create draft PRs +gh stack push --auto --draft + +# 6. Verify the stack +gh stack view --json +``` + +### Modify a mid-stack branch and sync + +This is the most common agent task after initial creation: change a branch that isn't at the top, then rebase and push. + +```bash +# 1. Navigate to the branch that needs changes +gh stack bottom +# or: gh stack checkout feat/auth +# or: gh stack checkout 42 (by PR number) + +# 2. Make changes and commit +cat > auth.go << 'EOF' +package auth +// updated implementation +EOF +git add -A && git commit -m "Fix auth token validation" + +# 3. Rebase everything above this branch +gh stack rebase --upstack + +# 4. Push the updated stack +gh stack push --auto +``` + +### Routine sync after merges + +```bash +# Single command: fetch, rebase, push, sync PR state +gh stack sync +``` + +### Squash-merge recovery + +When a PR is squash-merged on GitHub, the original branch's commits no longer exist in the trunk history. `gh stack` detects this automatically and uses `git rebase --onto` to correctly replay remaining commits. + +```bash +# After PR #1 (feat/auth) is squash-merged on GitHub: +gh stack sync +# → fetches latest, detects the merge, fast-forwards trunk +# → rebases feat/api-routes onto updated trunk using --onto (skips merged branch) +# → rebases feat/api-tests onto feat/api-routes +# → pushes updated branches +# → reports: "Merged: #1" + +# Verify the result +gh stack view --json +# → feat/auth shows "isMerged": true, "state": "MERGED" +# → feat/api-routes and feat/api-tests show updated heads +``` + +If `sync` hits a conflict during this process, it restores all branches to their pre-rebase state and exits with code 3. See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow. + +### Handle rebase conflicts (agent workflow) + +```bash +# 1. Start the rebase +gh stack rebase + +# 2. If exit code 3 (conflict): +# - Parse stderr for conflicted file paths +# - Read those files to find <<<<<<< / ======= / >>>>>>> markers +# - Edit files to resolve conflicts +# - Stage resolved files: +git add path/to/resolved-file.go + +# 3. Continue the rebase +gh stack rebase --continue + +# 4. If another conflict occurs, repeat steps 2-3 + +# 5. If unable to resolve, abort to restore everything +gh stack rebase --abort +``` + +### Parsing `--json` output + +```bash +# Get stack state as JSON +output=$(gh stack view --json) + +# Check if any branch needs a rebase +echo "$output" | jq '.branches[] | select(.needsRebase == true) | .name' + +# Get all open PR URLs +echo "$output" | jq -r '.branches[] | select(.pr.state == "OPEN") | .pr.url' + +# Find merged branches +echo "$output" | jq -r '.branches[] | select(.isMerged == true) | .name' + +# Get the current branch +echo "$output" | jq -r '.currentBranch' + +# Check if the stack is fully merged (all branches merged) +echo "$output" | jq '[.branches[] | .isMerged] | all' +``` + +### Clean up after all PRs are merged + +```bash +gh stack unstack --local +``` + +--- + +## Commands + +### Initialize a stack — `gh stack init` + +Creates a new stack. Provide branch names as positional arguments. + +``` +gh stack init [branches...] [flags] +``` + +```bash +# Create a stack with new branches (branched from trunk) +gh stack init branch-a branch-b branch-c + +# Use a different trunk branch +gh stack init --base develop branch-a branch-b + +# Adopt existing branches into a stack +gh stack init --adopt branch-a branch-b branch-c + +# Set a branch prefix for auto-naming (used by `add -m`) +gh stack init -p feat branch-a +``` + +| Flag | Description | +|------|-------------| +| `-b, --base ` | Trunk branch (defaults to the repo's default branch) | +| `-a, --adopt` | Adopt existing branches instead of creating new ones | +| `-p, --prefix ` | Set a branch name prefix for auto-generated names | + +**Behavior:** + +- Creates any branches that don't already exist (branching from the trunk branch) +- In `--adopt` mode: validates all branches exist, rejects if any is already in a stack or has an existing PR +- Checks out the last branch in the list +- Enables `git rerere` so conflict resolutions are remembered across rebases + +--- + +### Add a branch — `gh stack add` + +Add a new branch on top of the current stack. Must be run while on the topmost branch (or the trunk if the stack has no branches yet). Always provide an explicit branch name. + +``` +gh stack add [branch] [flags] +``` + +```bash +# Create a new branch and switch to it +gh stack add api-routes + +# Create a new branch, stage all changes, and commit +gh stack add -Am "Add API routes" api-routes + +# Create a new branch, stage tracked files only, and commit +gh stack add -um "Fix auth bug" auth-fix +``` + +You can also create the branch first, then use regular git to make changes: + +```bash +gh stack add api-routes +# ... write code ... +git add -A && git commit -m "Add API routes" +``` + +| Flag | Description | +|------|-------------| +| `-m, --message ` | Create a commit with this message | +| `-A, --all` | Stage all changes including untracked files (requires `-m`) | +| `-u, --update` | Stage tracked files only (requires `-m`) | + +**Behavior notes:** + +- `-A` and `-u` are mutually exclusive. +- When the current branch has no commits (e.g., right after `init`), `add -Am` commits directly on the current branch instead of creating a new one. +- If a prefix was set during `init`, the prefix is applied to branch names: `prefix/branch-name`. +- If called from a branch that is not the topmost in the stack, exits with code 5: `"can only add branches on top of the stack"`. Use `gh stack top` to switch first. + +--- + +### Push branches and create PRs — `gh stack push` + +Push all stack branches and create/update PRs. + +``` +gh stack push [flags] +``` + +```bash +# Push and auto-title new PRs +gh stack push --auto + +# Push and create PRs as drafts +gh stack push --auto --draft + +# Push branches only, no PR creation +gh stack push --skip-prs +``` + +| Flag | Description | +|------|-------------| +| `--auto` | Auto-generate PR titles without prompting | +| `--draft` | Create new PRs as drafts | +| `--skip-prs` | Push branches without creating or updating PRs | +| `--remote ` | Remote to push to (use if multiple remotes exist) | + +**Behavior:** + +- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`) +- Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch) +- Syncs PR metadata for branches that already have PRs + +**PR title auto-generation (`--auto`):** + +- Single commit on branch → uses the commit subject as the PR title, commit body as PR body +- Multiple commits on branch → humanizes the branch name (hyphens/underscores → spaces) as the title + +**Output (stderr):** + +- `Created PR #N for ` for each newly created PR +- `PR #N for is up to date` for existing PRs +- `Pushed and synced N branches` summary + +--- + +### Sync the stack — `gh stack sync` + +Fetch, rebase, push, and sync PR state in a single command. This is the recommended command for routine synchronization. + +``` +gh stack sync [flags] +``` + +| Flag | Description | +|------|-------------| +| `--remote ` | Remote to fetch from and push to (use if multiple remotes exist) | + +**What it does (in order):** + +1. **Fetch** latest changes from the remote +2. **Fast-forward trunk** to match remote (skips if already up to date, warns if diverged) +3. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles squash-merged PRs automatically with `--onto`. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow +4. **Push** all active branches atomically +5. **Sync PR state** from GitHub and report the status of each PR + +**Output (stderr):** + +- `✓ Fetched latest changes from origin` +- `✓ Trunk main fast-forwarded to ` or `✓ Trunk main is already up to date` +- `✓ Rebased onto ` per branch (if base moved) +- `✓ Pushed N branches` +- `✓ PR #N () — Open` per branch +- `Merged: #N, #M` for merged branches +- `✓ Stack synced` + +--- + +### Rebase the stack — `gh stack rebase` + +Pull from remote and cascade-rebase stack branches. Use this when `sync` reports a conflict or when you need finer control (e.g., rebase only part of the stack). + +``` +gh stack rebase [branch] [flags] +``` + +```bash +# Rebase the entire stack +gh stack rebase + +# Rebase only branches from trunk to current branch +gh stack rebase --downstack + +# Rebase only branches from current branch to top +gh stack rebase --upstack + +# After resolving a conflict: stage files with `git add`, then: +gh stack rebase --continue + +# Abort and restore all branches to pre-rebase state +gh stack rebase --abort +``` + +| Flag | Description | +|------|-------------| +| `--downstack` | Only rebase branches from trunk to the current branch | +| `--upstack` | Only rebase branches from the current branch to the top | +| `--continue` | Continue after resolving conflicts | +| `--abort` | Abort and restore all branches | +| `--remote ` | Remote to fetch from (use if multiple remotes exist) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | Target branch (defaults to the current branch) | + +**Conflict handling:** See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) in the Workflows section for the full resolution workflow. + +**Squash-merge detection:** If a branch's PR was squash-merged on GitHub, the rebase automatically uses `git rebase --onto` to correctly replay commits on top of the merge target. This is handled transparently. + +**Rerere (conflict memory):** `git rerere` is enabled by `init` so previously resolved conflicts are auto-resolved in future rebases. + +--- + +### View the stack — `gh stack view` + +Display the current stack's branches, PR status, and recent commits. Use `--json` for structured output. + +``` +gh stack view [flags] +``` + +```bash +# Structured JSON output (recommended) +gh stack view --json +``` + +| Flag | Description | +|------|-------------| +| `--json` | Output stack data as JSON to stdout | + +**`--json` output format:** + +```json +{ + "trunk": "main", + "prefix": "feat", + "currentBranch": "feat/api-routes", + "branches": [ + { + "name": "feat/auth", + "head": "abc1234...", + "base": "def5678...", + "isCurrent": false, + "isMerged": true, + "needsRebase": false, + "pr": { + "number": 42, + "url": "https://github.com/owner/repo/pull/42", + "state": "MERGED" + } + }, + { + "name": "feat/api-routes", + "head": "789abcd...", + "base": "abc1234...", + "isCurrent": true, + "isMerged": false, + "needsRebase": false, + "pr": { + "number": 43, + "url": "https://github.com/owner/repo/pull/43", + "state": "OPEN" + } + } + ] +} +``` + +Fields per branch: +- `name` — branch name +- `head` — current HEAD SHA +- `base` — parent branch's HEAD SHA at last sync +- `isCurrent` — whether this is the checked-out branch +- `isMerged` — whether the PR has been merged +- `needsRebase` — whether the base branch is not an ancestor (non-linear history) +- `pr` — PR metadata (omitted if no PR exists). `state` is `"OPEN"` or `"MERGED"`. + +> **Note:** `--short` outputs a compact text view with box-drawing characters and status icons. Prefer `--json` for programmatic use. + +--- + +### Navigate the stack + +Move between branches without remembering branch names. These commands are fully non-interactive. + +```bash +gh stack up # Move up one branch (further from trunk) +gh stack up 3 # Move up three branches +gh stack down # Move down one branch (closer to trunk) +gh stack down 2 # Move down two branches +gh stack top # Jump to the top of the stack (furthest from trunk) +gh stack bottom # Jump to the bottom (first non-merged branch above trunk) +``` + +Navigation clamps to stack bounds. Merged branches are skipped when navigating from active branches. + +--- + +### Check out a stack — `gh stack checkout` + +Check out a locally tracked stack by PR number or branch name. Always provide the target as an argument. + +``` +gh stack checkout +``` + +```bash +# By PR number +gh stack checkout 42 + +# By branch name +gh stack checkout feature-auth +``` + +Resolves the target against locally tracked stacks. Accepts a PR number, PR URL, or branch name. Checks out the matching branch. + +> **Note:** This command only works with stacks that have been created locally (via `gh stack init`). Server-side stack discovery is not yet implemented. + +--- + +### Remove a stack — `gh stack unstack` + +Remove a stack from local tracking. Use `--local` to avoid warnings about unsupported server-side deletion. + +``` +gh stack unstack [branch] [flags] +``` + +```bash +# Remove from local tracking +gh stack unstack --local + +# Specify a branch to identify which stack +gh stack unstack feature-auth --local +``` + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (recommended) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | A branch in the stack (defaults to the current branch) | + +--- + +## Output conventions + +- **Status messages** go to **stderr** with emoji prefixes: `✓` (success), `✗` (error), `⚠` (warning), `ℹ` (info). +- **Data output** (e.g., `view --json`) goes to **stdout**. +- When piping output, use `2>/dev/null` to suppress status messages if only data output is needed. + +## Exit codes and error recovery + +| Code | Meaning | Agent action | +|------|---------|-------------| +| 0 | Success | Proceed normally | +| 1 | Generic error | Read stderr for details; may indicate commit/push failure | +| 2 | Not in a stack | Run `gh stack init` to create a stack first | +| 3 | Rebase conflict | Parse stderr for conflicted file paths, resolve conflicts, run `gh stack rebase --continue` | +| 4 | GitHub API failure | Check `gh auth status`, retry the command | +| 5 | Invalid arguments | Fix the command invocation (check flags and arguments) | +| 6 | Disambiguation required | A branch belongs to multiple stacks. Run `gh stack checkout ` to switch to a non-shared branch first | +| 7 | Rebase already in progress | Run `gh stack rebase --continue` (after resolving conflicts) or `gh stack rebase --abort` to start over | + +## Known limitations + +1. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first. +2. **Multiple remotes require `--remote` or config.** If more than one remote is configured, pass `--remote ` or set `remote.pushDefault` in git config before running `push`, `sync`, or `rebase`. +3. **Merging PRs:** Merging Stacked PRs from the CLI is not supported yet. Direct users to open the PR URL in a browser to merge PRs. +4. **Server-side stack deletion is not supported.** Use `unstack --local`. +5. **Server-side stack discovery is not supported.** `checkout` only works with locally tracked stacks. +6. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `push`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation. From 30584211dbc3f8b9d95aa10fe7373a3085e188e1 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 14:00:23 -0400 Subject: [PATCH 69/78] default to named branches, numbering is a flag set on init --- README.md | 21 +++- cmd/add.go | 8 +- cmd/add_test.go | 1 + cmd/init.go | 154 +++++++++++++++---------- internal/branch/name.go | 14 +-- internal/branch/name_test.go | 11 +- internal/stack/stack.go | 1 + skills/gh-stack/SKILL.md | 211 ++++++++++++++++++++++++++++------- 8 files changed, 297 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 5245859..9984ccd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ Initialize a new stack in the current repository. gh stack init [branches...] [flags] ``` -Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix for auto-naming (unless adopting existing branches). When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. +Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix (unless adopting existing branches). When a prefix is set, branch names you enter are automatically prefixed. When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. + +Use `--numbered` with `--prefix` to enable auto-incrementing numbered branch names (`prefix/01`, `prefix/02`, …). Without `--numbered`, you'll always be prompted to provide a meaningful branch name. Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. @@ -80,6 +82,7 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | | `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | | `-p, --prefix ` | Set a branch name prefix for the stack | +| `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | **Examples:** @@ -96,8 +99,14 @@ gh stack init --base develop feature-auth # Adopt existing branches into a stack gh stack init --adopt feature-auth feature-api -# Set a prefix for auto-naming branches +# Set a prefix — you'll be prompted for a branch name gh stack init -p feat +# → prompts "Enter a name for the first branch (will be prefixed with feat/)" +# → type "auth" → creates feat/auth + +# Use numbered auto-incrementing branch names +gh stack init -p feat --numbered +# → creates feat/01 automatically ``` ### `gh stack add` @@ -110,7 +119,7 @@ gh stack add [branch] [flags] Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. -You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated. Auto-generated names use either numbered format (`prefix/01`, `prefix/02`) or date+slug format depending on prefix configuration and existing branch naming patterns. +You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated. If the stack was created with `--numbered`, auto-generated names use numbered format (`prefix/01`, `prefix/02`); otherwise, date+slug format is used (e.g., `prefix/2025-03-24-add-login`). | Flag | Description | |------|-------------| @@ -409,13 +418,13 @@ gh stack sync ## Abbreviated workflow -If you want to minimize keystrokes, use a branch prefix and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated from your commit messages. +If you want to minimize keystrokes, use a branch prefix with `--numbered` and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated as `prefix/01`, `prefix/02`, etc. When a branch has no commits yet (e.g., right after `init`), `add -Am` stages and commits directly on that branch instead of creating a new one. Once a branch has commits, `add -Am` creates a new branch, checks it out, and commits there. ```sh -# 1. Start a stack with a prefix -gh stack init -p feat +# 1. Start a stack with a prefix and numbered branches +gh stack init -p feat --numbered # → creates feat/01 and checks it out # 2. Write code for the first layer diff --git a/cmd/add.go b/cmd/add.go index 89d5d3a..9cb8dd0 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -111,8 +111,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if opts.message != "" { // Auto-naming mode - isFirstBranch := len(existingBranches) == 0 - name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, isFirstBranch) + name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, s.Numbered) if name == "" { cfg.Errorf("could not generate branch name") return ErrSilent @@ -124,10 +123,9 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } else if explicitName != "" { branchName = applyPrefix(cfg, s.Prefix, explicitName) } else { - // No -m, no explicit name — auto-generate if following numbered + // No -m, no explicit name — auto-generate if using numbered // convention, otherwise prompt for a name. - if s.Prefix != "" && len(existingBranches) > 0 && - branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) { + if s.Numbered && s.Prefix != "" { branchName = branch.NextNumberedName(s.Prefix, existingBranches) } else { p := prompter.New(cfg.In, cfg.Out, cfg.Err) diff --git a/cmd/add_test.go b/cmd/add_test.go index c1efb58..a975255 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -278,6 +278,7 @@ func TestAdd_NumberedNaming(t *testing.T) { gitDir := t.TempDir() saveStack(t, gitDir, stack.Stack{ Prefix: "feat", + Numbered: true, Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "feat/01"}}, }) diff --git a/cmd/init.go b/cmd/init.go index cf6926a..c14d703 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -18,6 +18,7 @@ type initOptions struct { base string adopt bool prefix string + numbered bool } func InitCmd(cfg *config.Config) *cobra.Command { @@ -43,6 +44,7 @@ Trunk defaults to default branch, unless specified otherwise.`, cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack") cmd.Flags().StringVarP(&opts.prefix, "prefix", "p", "", "Branch name prefix for the stack") + cmd.Flags().BoolVarP(&opts.numbered, "numbered", "n", false, "Use auto-incrementing numbered branch names (requires --prefix)") return cmd } @@ -99,6 +101,13 @@ func runInit(cfg *config.Config, opts *initOptions) error { var branches []string + // Validate --numbered requires a prefix (either from flag or interactive input, + // but for non-interactive paths we can check early). + if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() { + cfg.Errorf("--numbered requires --prefix") + return ErrInvalidArgs + } + if opts.adopt { // Adopt mode: validate all specified branches exist if len(opts.branches) == 0 { @@ -160,69 +169,40 @@ func runInit(cfg *config.Config, opts *initOptions) error { // Step 1: Ask for prefix if opts.prefix == "" { - prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to read prefix: %s", err) - return ErrSilent - } - opts.prefix = strings.TrimSpace(prefixInput) - } - - // Step 2: Ask for branch name - if currentBranch != "" && currentBranch != trunk { - // Already on a non-trunk branch — offer to use it - useCurrentBranch, err := p.Confirm( - fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch), - true, - ) - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) + if opts.numbered { + // --numbered requires a prefix; prompt specifically for one + prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to read prefix: %s", err) return ErrSilent } - cfg.Errorf("failed to confirm branch selection: %s", err) - return ErrSilent - } - if useCurrentBranch { - if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { - cfg.Errorf("branch %q already exists in the stack", currentBranch) + opts.prefix = strings.TrimSpace(prefixInput) + if opts.prefix == "" { + cfg.Errorf("--numbered requires a prefix") return ErrInvalidArgs } - branches = []string{currentBranch} - } - } - - if len(branches) == 0 { - prompt := "What branch would you like to use as the first layer of your stack?" - if opts.prefix != "" { - prompt = fmt.Sprintf("Name the first branch, or leave blank to use %s", branch.NextNumberedName(opts.prefix, nil)) - } - branchName, err := p.Input(prompt, "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) + } else { + prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to read prefix: %s", err) return ErrSilent } - cfg.Errorf("failed to read branch name: %s", err) - return ErrSilent - } - branchName = strings.TrimSpace(branchName) - - if branchName == "" && opts.prefix != "" { - // Auto-generate numbered branch name - branchName = branch.NextNumberedName(opts.prefix, nil) - } else if branchName == "" { - cfg.Errorf("branch name cannot be empty") - return ErrInvalidArgs - } else if opts.prefix != "" { - // Prepend prefix to the user-provided name - branchName = opts.prefix + "/" + branchName + opts.prefix = strings.TrimSpace(prefixInput) } + } + // Step 2: Ask for branch name (unless --numbered auto-generates it) + if opts.numbered { + // Auto-generate numbered branch name + branchName := branch.NextNumberedName(opts.prefix, nil) if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { cfg.Errorf("branch %q already exists in a stack", branchName) return ErrInvalidArgs @@ -234,6 +214,67 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } branches = []string{branchName} + } else { + if currentBranch != "" && currentBranch != trunk { + // Already on a non-trunk branch — offer to use it + useCurrentBranch, err := p.Confirm( + fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch), + true, + ) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to confirm branch selection: %s", err) + return ErrSilent + } + if useCurrentBranch { + if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { + cfg.Errorf("branch %q already exists in the stack", currentBranch) + return ErrInvalidArgs + } + branches = []string{currentBranch} + } + } + + if len(branches) == 0 { + prompt := "What branch would you like to use as the first layer of your stack?" + if opts.prefix != "" { + prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", opts.prefix) + } + branchName, err := p.Input(prompt, "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to read branch name: %s", err) + return ErrSilent + } + branchName = strings.TrimSpace(branchName) + + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return ErrInvalidArgs + } + + if opts.prefix != "" { + branchName = opts.prefix + "/" + branchName + } + + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in a stack", branchName) + return ErrInvalidArgs + } + if !git.BranchExists(branchName) { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return ErrSilent + } + } + branches = []string{branchName} + } } } @@ -258,7 +299,8 @@ func runInit(cfg *config.Config, opts *initOptions) error { } newStack := stack.Stack{ - Prefix: opts.prefix, + Prefix: opts.prefix, + Numbered: opts.numbered, Trunk: stack.BranchRef{ Branch: trunk, Head: trunkSHA, diff --git a/internal/branch/name.go b/internal/branch/name.go index 8ebcd42..c1df800 100644 --- a/internal/branch/name.go +++ b/internal/branch/name.go @@ -97,10 +97,10 @@ func NextNumberedName(prefix string, existingBranches []string) string { // - message: commit message (from -m flag; may be empty if not using auto-naming) // - explicitName: branch name provided as argument (may be empty) // - existingBranches: current branch names in the stack -// - isFirstBranch: true if this is the first branch being added to the stack +// - numbered: true if the stack uses auto-incrementing numbered branches // // Returns the resolved branch name and an informational message (may be empty). -func ResolveBranchName(prefix, message, explicitName string, existingBranches []string, isFirstBranch bool) (name string, info string) { +func ResolveBranchName(prefix, message, explicitName string, existingBranches []string, numbered bool) (name string, info string) { if explicitName != "" { // Explicit name provided if prefix != "" { @@ -118,18 +118,10 @@ func ResolveBranchName(prefix, message, explicitName string, existingBranches [] } if prefix != "" { - // Check if we should use numbered format - useNumbering := isFirstBranch - if !useNumbering && len(existingBranches) > 0 { - lastBranch := existingBranches[len(existingBranches)-1] - useNumbering = FollowsNumbering(prefix, lastBranch) - } - - if useNumbering { + if numbered { name = NextNumberedName(prefix, existingBranches) } else { name = prefix + "/" + DateSlug(message) - info = "Branch name auto-generated using date+slug format because existing branches don't follow numbering convention" } } else { // No prefix — always use date+slug diff --git a/internal/branch/name_test.go b/internal/branch/name_test.go index 95c786b..04df0eb 100644 --- a/internal/branch/name_test.go +++ b/internal/branch/name_test.go @@ -98,24 +98,23 @@ func TestResolveBranchName(t *testing.T) { assert.Empty(t, info) }) - t.Run("message with prefix first branch uses numbered format", func(t *testing.T) { + t.Run("message with prefix and numbered uses numbered format", func(t *testing.T) { name, _ := ResolveBranchName("stack", "add login", "", nil, true) assert.Equal(t, "stack/01", name) }) - t.Run("message with prefix last branch follows numbering uses next number", func(t *testing.T) { + t.Run("message with prefix and numbered continues sequence", func(t *testing.T) { existing := []string{"stack/01", "stack/02"} - name, _ := ResolveBranchName("stack", "add login", "", existing, false) + name, _ := ResolveBranchName("stack", "add login", "", existing, true) assert.Equal(t, "stack/03", name) }) - t.Run("message with prefix last branch not numbered uses date-slug", func(t *testing.T) { + t.Run("message with prefix not numbered uses date-slug", func(t *testing.T) { existing := []string{"stack/some-feature"} - name, info := ResolveBranchName("stack", "add login", "", existing, false) + name, _ := ResolveBranchName("stack", "add login", "", existing, false) today := time.Now().Format("2006-01-02") assert.True(t, strings.HasPrefix(name, "stack/"+today), "expected date prefix, got: %s", name) assert.Contains(t, name, "add-login") - assert.NotEmpty(t, info, "should explain why date+slug was used") }) t.Run("message without prefix uses date-slug", func(t *testing.T) { diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 2456e1a..4dca5a6 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -37,6 +37,7 @@ type BranchRef struct { type Stack struct { ID string `json:"id,omitempty"` Prefix string `json:"prefix,omitempty"` + Numbered bool `json:"numbered,omitempty"` Trunk BranchRef `json:"trunk"` Branches []BranchRef `json:"branches"` } diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 7dcc4b4..66f665c 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -48,18 +48,75 @@ gh extension install github/gh-stack 3. **Always use `--json` when viewing** to get structured output. 4. **Use `--remote ` when multiple remotes are configured**, or set `remote.pushDefault` in git config. 5. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. +6. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`. +7. **Use standard `git add` and `git commit` for staging and committing.** This gives you full control over which changes go into each branch. The `-Am` shortcut is available but should not be the default approach—stacked PRs are most effective when each branch contains a deliberate, logical set of changes. +8. **Navigate down the stack when you need to change a lower layer.** If you're working on a frontend branch and realize you need API changes, don't hack around it at the current layer. Navigate to the appropriate branch (`gh stack down`, `gh stack checkout`, or `gh stack bottom`), make and commit the changes there, run `gh stack rebase --upstack`, then navigate back up to continue. + +## Thinking about stack structure + +Each branch in a stack should represent a **discrete, logical unit of work** that can be reviewed independently. The changes within a branch should be cohesive—they belong together and make sense as a single PR. + +### Dependency chain + +Stacked branches form a dependency chain: each branch builds on the one below it. This means **foundational changes must go in lower (earlier) branches**, and code that depends on them goes in higher (later) branches. + +**Plan your layers before writing code.** For example, a full-stack feature might be structured like this (use branch names relevant to your actual task, not these generic ones): + +``` +main (trunk) + └── data-models ← shared types, database schema + └── api-endpoints ← API routes that use the models + └── frontend-ui ← UI components that call the APIs + └── integration ← tests that exercise the full stack +``` + +This is illustrative — choose branch names and layer boundaries that reflect the specific work you're doing. The key principle is: if code in one layer depends on code in another, the dependency must be in the same branch or a lower one. + +### Staging changes deliberately + +Don't dump all changes into a single commit or branch. Stage changes in batches based on logical grouping: + +```bash +# Stage only the model files for this branch +git add internal/models/user.go internal/models/session.go +git commit -m "Add user and session models" + +# Stage related migration +git add db/migrations/001_create_users.sql +git commit -m "Add user table migration" +``` + +Multiple commits per branch are fine and encouraged—they make the PR easier to review. The key is that all commits in a branch relate to the same logical concern. + +### When to create a new branch + +Create a new branch (`gh stack add`) when you're starting a **different concern** that depends on what you've built so far. Signs it's time for a new branch: + +- You're switching from backend to frontend work +- You're moving from core logic to tests or documentation +- The next set of changes has a different reviewer audience +- The current branch's PR is already large enough to review + +### One stack, one story + +Think of a stack from the reviewer's perspective: the stack of PRs should **tell a cohesive story** about a feature or project. A reviewer should be able to read the PRs in sequence and understand the progression of changes, with each PR being a small, logical piece of the whole. + +**When to use a single stack:** All the branches are part of the same feature, project, or closely related effort. Even if the work spans multiple concerns (models, API, frontend), they're all building toward the same goal. + +**When to create a separate stack:** The work is unrelated to your current stack — a different feature, a bug fix in an unrelated area, or an independent refactor. Don't mix unrelated work into a single stack just because you happen to be working on both. Start a new stack with `gh stack init` or switch to an existing stack with `gh stack checkout` for each distinct effort. + +Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current stack if they're trivial. But if a change grows into its own project, it deserves its own stack. ## Quick reference | Task | Command | |------|---------| -| Create a stack | `gh stack init -p feat auth` | -| Create a stack with explicit branch names (no prefix) | `gh stack init branch-a` | +| Create a stack | `gh stack init branch-a` | +| Create a stack with a prefix | `gh stack init -p feat auth` | | Adopt existing branches | `gh stack init --adopt branch-a branch-b` | | Set custom trunk | `gh stack init --base develop branch-a` | | Add a branch to stack | `gh stack add branch-name` | -| Add branch + stage all + commit | `gh stack add -Am "message" new-branch` | -| Add branch + stage tracked + commit | `gh stack add -um "message" new-branch` | +| Add branch + stage all + commit (shortcut) | `gh stack add -Am "message" new-branch` | | Push + create PRs | `gh stack push --auto` | | Push as drafts | `gh stack push --auto --draft` | | Push without creating PRs | `gh stack push --skip-prs` | @@ -67,6 +124,7 @@ gh extension install github/gh-stack | Sync (fetch, rebase, push) | `gh stack sync` | | Sync with specific remote | `gh stack sync --remote origin` | | Rebase entire stack | `gh stack rebase` | +| Rebase upstack only | `gh stack rebase --upstack` | | Continue after conflict | `gh stack rebase --continue` | | Abort rebase | `gh stack rebase --abort` | | View stack details (JSON) | `gh stack view --json` | @@ -83,10 +141,11 @@ gh extension install github/gh-stack ### End-to-end: create a stack from scratch ```bash -# 1. Initialize a stack with a prefix and first branch (feat/auth) +# 1. Initialize a stack with the first branch gh stack init -p feat auth +# → creates feat/auth and checks it out -# 2. Write code and commit on the first branch +# 2. Write code for the first layer (auth) cat > auth.go << 'EOF' package auth @@ -97,10 +156,27 @@ func Middleware(next http.Handler) http.Handler { }) } EOF -gh stack add -Am "Add auth middleware" -# → commits on feat/auth (no commits yet, so stays on current branch) -# 3. Write more code and create the next layer +# 3. Stage and commit using standard git commands +git add auth.go +git commit -m "Add auth middleware" + +# You can make multiple commits on the same branch +cat > auth_test.go << 'EOF' +package auth + +func TestMiddleware(t *testing.T) { + // test auth middleware +} +EOF +git add auth_test.go +git commit -m "Add auth middleware tests" + +# 4. When you're ready for a new concern, add the next branch +gh stack add api-routes +# → creates feat/api-routes (prefixed), checks it out + +# 5. Write code for the API layer cat > api.go << 'EOF' package api @@ -108,30 +184,71 @@ func RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/users", handleUsers) } EOF -gh stack add -Am "Add API routes" api-routes -# → creates feat/api-routes, checks it out, commits there +git add api.go +git commit -m "Add API routes" -# 4. Add a third layer -cat > api_test.go << 'EOF' -package api +# 6. Add a third layer for frontend +gh stack add frontend +# → creates feat/frontend, checks it out + +cat > frontend.go << 'EOF' +package frontend -func TestRegisterRoutes(t *testing.T) { - // test routes +func RenderDashboard(w http.ResponseWriter) { + // calls the API endpoints from the layer below } EOF -gh stack add -Am "Add API tests" api-tests -# → creates feat/api-tests, checks it out, commits there +git add frontend.go +git commit -m "Add frontend dashboard" -# 5. Push everything and create draft PRs +# ── Stack complete: feat/auth → feat/api-routes → feat/frontend ── + +# 7. Push everything and create draft PRs gh stack push --auto --draft -# 6. Verify the stack +# 8. Verify the stack gh stack view --json ``` +> **Shortcut:** If you prefer a faster flow, `gh stack add -Am "message" branch-name` combines staging, committing, and branch creation into one command. This is useful for single-commit layers but bypasses deliberate staging. + +### Making mid-stack changes + +This is a critical workflow for agents. When you're working on a higher layer and realize you need to change something in a lower layer (e.g., you're building frontend components but need to add an API endpoint), **navigate down to the correct branch, make the change there, and rebase**. + +```bash +# You're on feat/frontend but need to add an API endpoint + +# 1. Navigate to the API branch +gh stack down +# or: gh stack checkout feat/api-routes + +# 2. Make the change where it belongs +cat > users_api.go << 'EOF' +package api + +func handleGetUser(w http.ResponseWriter, r *http.Request) { + // new endpoint the frontend needs +} +EOF +git add users_api.go +git commit -m "Add get-user endpoint" + +# 3. Rebase everything above to pick up the change +gh stack rebase --upstack + +# 4. Navigate back to where you were working +gh stack top +# or: gh stack checkout feat/frontend + +# 5. Continue working — the API changes are now available +``` + +**Why this matters:** If you make API changes on the frontend branch, those changes will end up in the wrong PR. The API PR won't include them, and the frontend PR will have unrelated API diffs mixed in. Always put changes in the branch where they logically belong. + ### Modify a mid-stack branch and sync -This is the most common agent task after initial creation: change a branch that isn't at the top, then rebase and push. +When you need to revisit a branch after the initial creation (e.g., responding to review feedback): ```bash # 1. Navigate to the branch that needs changes @@ -144,7 +261,8 @@ cat > auth.go << 'EOF' package auth // updated implementation EOF -git add -A && git commit -m "Fix auth token validation" +git add auth.go +git commit -m "Fix auth token validation" # 3. Rebase everything above this branch gh stack rebase --upstack @@ -209,8 +327,12 @@ gh stack rebase --abort # Get stack state as JSON output=$(gh stack view --json) -# Check if any branch needs a rebase -echo "$output" | jq '.branches[] | select(.needsRebase == true) | .name' +# Check if any branch needs a rebase, and rebase if so +needs_rebase=$(echo "$output" | jq '[.branches[] | select(.needsRebase == true)] | length') +if [ "$needs_rebase" -gt 0 ]; then + echo "Branches need rebase, rebasing stack..." + gh stack rebase +fi # Get all open PR URLs echo "$output" | jq -r '.branches[] | select(.pr.state == "OPEN") | .pr.url' @@ -253,8 +375,9 @@ gh stack init --base develop branch-a branch-b # Adopt existing branches into a stack gh stack init --adopt branch-a branch-b branch-c -# Set a branch prefix for auto-naming (used by `add -m`) -gh stack init -p feat branch-a +# Set a branch prefix (branch names you provide are automatically prefixed) +gh stack init -p feat auth +# → creates feat/auth ``` | Flag | Description | @@ -280,23 +403,29 @@ Add a new branch on top of the current stack. Must be run while on the topmost b gh stack add [branch] [flags] ``` +**Recommended workflow — create the branch, then use standard git:** + ```bash # Create a new branch and switch to it gh stack add api-routes -# Create a new branch, stage all changes, and commit -gh stack add -Am "Add API routes" api-routes +# Write code, stage deliberately, and commit +git add internal/api/routes.go internal/api/handlers.go +git commit -m "Add user API routes" -# Create a new branch, stage tracked files only, and commit -gh stack add -um "Fix auth bug" auth-fix +# Make more commits on the same branch as needed +git add internal/api/middleware.go +git commit -m "Add rate limiting middleware" ``` -You can also create the branch first, then use regular git to make changes: +**Shortcut — stage, commit, and branch in one command:** ```bash -gh stack add api-routes -# ... write code ... -git add -A && git commit -m "Add API routes" +# Create a new branch, stage all changes, and commit +gh stack add -Am "Add API routes" api-routes + +# Create a new branch, stage tracked files only, and commit +gh stack add -um "Fix auth bug" auth-fix ``` | Flag | Description | @@ -311,6 +440,7 @@ git add -A && git commit -m "Add API routes" - When the current branch has no commits (e.g., right after `init`), `add -Am` commits directly on the current branch instead of creating a new one. - If a prefix was set during `init`, the prefix is applied to branch names: `prefix/branch-name`. - If called from a branch that is not the topmost in the stack, exits with code 5: `"can only add branches on top of the stack"`. Use `gh stack top` to switch first. +- **Uncommitted changes:** When using `gh stack add branch-name` without `-Am`, any uncommitted changes (staged or unstaged) in your working tree carry over to the new branch. This is standard git behavior — the working tree is not touched. Commit or stash changes on the current branch before running `add` if you want a clean starting point on the new branch. --- @@ -590,9 +720,10 @@ gh stack unstack feature-auth --local ## Known limitations -1. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first. -2. **Multiple remotes require `--remote` or config.** If more than one remote is configured, pass `--remote ` or set `remote.pushDefault` in git config before running `push`, `sync`, or `rebase`. -3. **Merging PRs:** Merging Stacked PRs from the CLI is not supported yet. Direct users to open the PR URL in a browser to merge PRs. -4. **Server-side stack deletion is not supported.** Use `unstack --local`. -5. **Server-side stack discovery is not supported.** `checkout` only works with locally tracked stacks. -6. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `push`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation. +1. **Stacks are strictly linear.** Branching stacks (multiple children on a single parent) are not supported. Each branch has exactly one parent and at most one child. If you need parallel workstreams, use separate stacks. +2. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first. +3. **Multiple remotes require `--remote` or config.** If more than one remote is configured, pass `--remote ` or set `remote.pushDefault` in git config before running `push`, `sync`, or `rebase`. +4. **Merging PRs:** Merging Stacked PRs from the CLI is not supported yet. Direct users to open the PR URL in a browser to merge PRs. +5. **Server-side stack deletion is not supported.** Use `unstack --local`. +6. **Server-side stack discovery is not supported.** `checkout` only works with locally tracked stacks. +7. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `push`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation. From f03f9f333586b0aa5c51cf44ba104d920cbcf972 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 14:34:47 -0400 Subject: [PATCH 70/78] fix: return early from sync on rebase conflict When a rebase conflict was detected during 'gh stack sync', the function continued to push branches and reported 'Stack synced' with exit code 0. Now it persists PR state and returns ErrConflict immediately, preventing the confusing post-conflict push and success message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/sync.go | 5 +++++ cmd/sync_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/sync.go b/cmd/sync.go index 5e8cd0b..840f6dd 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -218,6 +218,11 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if !conflicted { rebased = true _ = git.CheckoutBranch(currentBranch) + } else { + // Persist refreshed PR state even on conflict, then bail out + // before pushing or reporting success. + _ = stack.Save(gitDir, sf) + return ErrConflict } } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index d74ece6..8ac3626 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -368,7 +368,7 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { errOut, _ := io.ReadAll(errR) output := string(errOut) - assert.NoError(t, err, "sync returns nil (errors printed via cfg)") + assert.Error(t, err, "sync returns error on conflict") assert.Contains(t, output, "Conflict detected") assert.Contains(t, output, "gh stack rebase") From 0c27492b713a3db744c3e480d986dad7c42110d6 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 16:44:35 -0400 Subject: [PATCH 71/78] more test coverage and clean up --- cmd/add_test.go | 141 ++++++++------- cmd/checkout_test.go | 141 +++++++++++++++ cmd/init_test.go | 95 +++-------- cmd/merge_test.go | 4 - cmd/navigate_test.go | 39 +++++ cmd/push_test.go | 180 +++++++++++++++++++ cmd/rebase_test.go | 399 ++++++++++++++++++++++++++++++++++++++----- cmd/sync_test.go | 146 +++++++++++++++- cmd/unstack_test.go | 116 +++++++++++++ cmd/view_test.go | 79 +++++++++ 10 files changed, 1147 insertions(+), 193 deletions(-) create mode 100644 cmd/checkout_test.go create mode 100644 cmd/unstack_test.go diff --git a/cmd/add_test.go b/cmd/add_test.go index a975255..e6238c0 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -1,12 +1,13 @@ package cmd import ( - "strings" "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" + "github.com/stretchr/testify/require" ) // saveStack is a helper to pre-create a stack file for add tests. @@ -16,9 +17,7 @@ func saveStack(t *testing.T, gitDir string, s stack.Stack) { SchemaVersion: 1, Stacks: []stack.Stack{s}, } - if err := stack.Save(gitDir, sf); err != nil { - t.Fatalf("saving seed stack: %v", err) - } + require.NoError(t, stack.Save(gitDir, sf), "saving seed stack") } func TestAdd_CreatesNewBranch(t *testing.T) { @@ -47,24 +46,14 @@ func TestAdd_CreatesNewBranch(t *testing.T) { runAdd(cfg, &addOptions{}, []string{"newbranch"}) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } - if createdBranch != "newbranch" { - t.Errorf("CreateBranch got %q, want %q", createdBranch, "newbranch") - } - if checkedOut != "newbranch" { - t.Errorf("CheckoutBranch got %q, want %q", checkedOut, "newbranch") - } + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "newbranch", createdBranch, "CreateBranch") + assert.Equal(t, "newbranch", checkedOut, "CheckoutBranch") sf, err := stack.Load(gitDir) - if err != nil { - t.Fatalf("loading stack: %v", err) - } + require.NoError(t, err, "loading stack") names := sf.Stacks[0].BranchNames() - if names[len(names)-1] != "newbranch" { - t.Errorf("top branch = %q, want %q", names[len(names)-1], "newbranch") - } + assert.Equal(t, "newbranch", names[len(names)-1], "top branch") } func TestAdd_OnlyAllowedOnTopOfStack(t *testing.T) { @@ -88,9 +77,7 @@ func TestAdd_OnlyAllowedOnTopOfStack(t *testing.T) { runAdd(cfg, &addOptions{}, []string{"newbranch"}) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "top of the stack") { - t.Errorf("expected 'top of the stack' error, got: %s", output) - } + assert.Contains(t, output, "top of the stack") } func TestAdd_MutuallyExclusiveFlags(t *testing.T) { @@ -101,9 +88,7 @@ func TestAdd_MutuallyExclusiveFlags(t *testing.T) { runAdd(cfg, &addOptions{stageAll: true, stageTracked: true, message: "msg"}, []string{"branch"}) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "mutually exclusive") { - t.Errorf("expected 'mutually exclusive' error, got: %s", output) - } + assert.Contains(t, output, "mutually exclusive") } func TestAdd_StagingWithoutMessageUsesEditor(t *testing.T) { @@ -135,9 +120,7 @@ func TestAdd_StagingWithoutMessageUsesEditor(t *testing.T) { cfg, _, _ := config.NewTestConfig() runAdd(cfg, &addOptions{stageAll: true}, []string{"new-branch"}) - if !interactiveCalled { - t.Error("expected CommitInteractive to be called when -m is omitted") - } + assert.True(t, interactiveCalled, "expected CommitInteractive to be called when -m is omitted") } func TestAdd_EmptyBranchCommitsInPlace(t *testing.T) { @@ -154,8 +137,9 @@ func TestAdd_EmptyBranchCommitsInPlace(t *testing.T) { restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "b1", nil }, - LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { - return nil, nil // no unique commits — branch is empty + RevParseMultiFn: func(refs []string) ([]string, error) { + // Return same SHA for parent and current branch — branch has no unique commits + return []string{"aaa111", "aaa111"}, nil }, StageAllFn: func() error { stageAllCalled = true @@ -177,18 +161,10 @@ func TestAdd_EmptyBranchCommitsInPlace(t *testing.T) { runAdd(cfg, &addOptions{stageAll: true, message: "Auth middleware"}, nil) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } - if !stageAllCalled { - t.Error("expected StageAll to be called") - } - if !commitCalled { - t.Error("expected Commit to be called") - } - if createBranchCalled { - t.Error("CreateBranch should NOT be called for empty branch commit-in-place") - } + require.NotContains(t, output, "\u2717", "unexpected error") + assert.True(t, stageAllCalled, "expected StageAll to be called") + assert.True(t, commitCalled, "expected Commit to be called") + assert.False(t, createBranchCalled, "CreateBranch should NOT be called for empty branch commit-in-place") } func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) { @@ -229,18 +205,10 @@ func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) { runAdd(cfg, &addOptions{stageAll: true, message: "API routes"}, nil) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } - if !createCalled { - t.Error("expected CreateBranch to be called") - } - if !checkoutCalled { - t.Error("expected CheckoutBranch to be called") - } - if !commitCalled { - t.Error("expected Commit to be called on the new branch") - } + require.NotContains(t, output, "\u2717", "unexpected error") + assert.True(t, createCalled, "expected CreateBranch to be called") + assert.True(t, checkoutCalled, "expected CheckoutBranch to be called") + assert.True(t, commitCalled, "expected Commit to be called on the new branch") } func TestAdd_PrefixAppliedWithSlash(t *testing.T) { @@ -266,12 +234,8 @@ func TestAdd_PrefixAppliedWithSlash(t *testing.T) { runAdd(cfg, &addOptions{}, []string{"mybranch"}) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } - if createdBranch != "feat/mybranch" { - t.Errorf("created branch = %q, want %q", createdBranch, "feat/mybranch") - } + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "feat/mybranch", createdBranch) } func TestAdd_NumberedNaming(t *testing.T) { @@ -305,12 +269,8 @@ func TestAdd_NumberedNaming(t *testing.T) { runAdd(cfg, &addOptions{stageAll: true, message: "next feature"}, nil) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } - if createdBranch != "feat/02" { - t.Errorf("created branch = %q, want %q", createdBranch, "feat/02") - } + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "feat/02", createdBranch) } func TestAdd_FullyMergedStackBlocked(t *testing.T) { @@ -333,9 +293,7 @@ func TestAdd_FullyMergedStackBlocked(t *testing.T) { runAdd(cfg, &addOptions{}, []string{"newbranch"}) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "All branches in this stack have been merged") { - t.Errorf("expected merged warning, got: %s", output) - } + assert.Contains(t, output, "All branches in this stack have been merged") } func TestAdd_NothingToCommit(t *testing.T) { @@ -360,7 +318,46 @@ func TestAdd_NothingToCommit(t *testing.T) { runAdd(cfg, &addOptions{stageAll: true, message: "msg"}, nil) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "no changes to commit") { - t.Errorf("expected 'no changes to commit' error, got: %s", output) - } + assert.Contains(t, output, "no changes to commit") +} + +func TestAdd_FromTrunk(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + var createdBranch string + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + // When on trunk, idx < 0 so the middle-of-stack check passes. + // Add should succeed and create the new branch. + require.NoError(t, err) + assert.Equal(t, "newbranch", createdBranch) + assert.Equal(t, "newbranch", checkedOut) + assert.NotContains(t, output, "\u2717") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + names := sf.Stacks[0].BranchNames() + assert.Equal(t, "newbranch", names[len(names)-1], "new branch should be appended to stack") } diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go new file mode 100644 index 0000000..1862699 --- /dev/null +++ b/cmd/checkout_test.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "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" + "github.com/stretchr/testify/require" +) + +func TestCheckout_ByBranchName(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return 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 := runCheckout(cfg, &checkoutOptions{target: "b2"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b2", checkedOut) + assert.Contains(t, output, "Switched to b2") +} + +func TestCheckout_ByPRNumber(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "42"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b1", checkedOut) + assert.Contains(t, output, "Switched to b1") +} + +func TestCheckout_AlreadyOnTarget(t *testing.T) { + gitDir := t.TempDir() + checkoutCalled := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkoutCalled = true + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "b1"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.False(t, checkoutCalled, "CheckoutBranch should not be called when already on target") + assert.Contains(t, output, "Already on b1") +} + +func TestCheckout_NoStacks_NonInteractive(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + // Write an empty stack file (no stacks) + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{}) // no target arg + output := collectOutput(cfg, outR, errR) + + assert.Error(t, err) + assert.Contains(t, output, "no target specified") +} + +func TestCheckout_BranchNotFound(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "nonexistent"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "no locally tracked stack found") +} diff --git a/cmd/init_test.go b/cmd/init_test.go index 4ebea16..773b078 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -3,12 +3,13 @@ package cmd import ( "io" "os" - "strings" "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" + "github.com/stretchr/testify/require" ) // collectOutput closes the write ends of the test config pipes and returns @@ -35,25 +36,16 @@ func TestInit_CreatesStackWithCorrectTrunk(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"myBranch"}}) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error in output: %s", output) - } + require.NotContains(t, output, "\u2717", "unexpected error in output") sf, err := stack.Load(gitDir) - if err != nil { - t.Fatalf("loading stack: %v", err) - } - if len(sf.Stacks) != 1 { - t.Fatalf("got %d stacks, want 1", len(sf.Stacks)) - } + require.NoError(t, err, "loading stack") + require.Len(t, sf.Stacks, 1) s := sf.Stacks[0] - if s.Trunk.Branch != "main" { - t.Errorf("trunk = %q, want %q", s.Trunk.Branch, "main") - } + assert.Equal(t, "main", s.Trunk.Branch) names := s.BranchNames() - if len(names) != 1 || names[0] != "myBranch" { - t.Errorf("branches = %v, want [myBranch]", names) - } + require.Len(t, names, 1) + assert.Equal(t, "myBranch", names[0]) } func TestInit_CustomTrunk(t *testing.T) { @@ -68,17 +60,11 @@ func TestInit_CustomTrunk(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"myBranch"}, base: "develop"}) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } + require.NotContains(t, output, "\u2717", "unexpected error") sf, err := stack.Load(gitDir) - if err != nil { - t.Fatalf("loading stack: %v", err) - } - if got := sf.Stacks[0].Trunk.Branch; got != "develop" { - t.Errorf("trunk = %q, want %q", got, "develop") - } + require.NoError(t, err, "loading stack") + assert.Equal(t, "develop", sf.Stacks[0].Trunk.Branch) } func TestInit_AdoptExistingBranches(t *testing.T) { @@ -98,24 +84,12 @@ func TestInit_AdoptExistingBranches(t *testing.T) { }) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } + require.NotContains(t, output, "\u2717", "unexpected error") sf, err := stack.Load(gitDir) - if err != nil { - t.Fatalf("loading stack: %v", err) - } + require.NoError(t, err, "loading stack") names := sf.Stacks[0].BranchNames() - want := []string{"b1", "b2", "b3"} - if len(names) != len(want) { - t.Fatalf("branches = %v, want %v", names, want) - } - for i, name := range names { - if name != want[i] { - t.Errorf("branch[%d] = %q, want %q", i, name, want[i]) - } - } + assert.Equal(t, []string{"b1", "b2", "b3"}, names) } func TestInit_PrefixStoredInStack(t *testing.T) { @@ -132,12 +106,8 @@ func TestInit_PrefixStoredInStack(t *testing.T) { collectOutput(cfg, outR, errR) sf, err := stack.Load(gitDir) - if err != nil { - t.Fatalf("loading stack: %v", err) - } - if got := sf.Stacks[0].Prefix; got != "feat" { - t.Errorf("prefix = %q, want %q", got, "feat") - } + require.NoError(t, err, "loading stack") + assert.Equal(t, "feat", sf.Stacks[0].Prefix) } func TestInit_RerereAlreadyEnabled(t *testing.T) { @@ -159,9 +129,7 @@ func TestInit_RerereAlreadyEnabled(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"b1"}}) collectOutput(cfg, outR, errR) - if enableRerereCalled { - t.Error("EnableRerere should not be called when rerere is already enabled") - } + assert.False(t, enableRerereCalled, "EnableRerere should not be called when rerere is already enabled") } func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { @@ -175,9 +143,7 @@ func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { Branches: []stack.BranchRef{{Branch: "feature-1"}}, }}, } - if err := stack.Save(gitDir, sf); err != nil { - t.Fatalf("saving seed stack: %v", err) - } + require.NoError(t, stack.Save(gitDir, sf), "saving seed stack") restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -190,9 +156,7 @@ func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"newBranch"}}) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "already part of a stack") { - t.Errorf("expected 'already part of a stack' error, got: %s", output) - } + assert.Contains(t, output, "already part of a stack") } func TestInit_AdoptNonexistentBranch(t *testing.T) { @@ -209,9 +173,7 @@ func TestInit_AdoptNonexistentBranch(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) output := collectOutput(cfg, outR, errR) - if !strings.Contains(output, "does not exist") { - t.Errorf("expected 'does not exist' error, got: %s", output) - } + assert.Contains(t, output, "does not exist") } func TestInit_MultipleBranches_CreatesAll(t *testing.T) { @@ -232,21 +194,10 @@ func TestInit_MultipleBranches_CreatesAll(t *testing.T) { runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) output := collectOutput(cfg, outR, errR) - if strings.Contains(output, "\u2717") { - t.Fatalf("unexpected error: %s", output) - } + require.NotContains(t, output, "\u2717", "unexpected error") sf, err := stack.Load(gitDir) - if err != nil { - t.Fatalf("loading stack: %v", err) - } + require.NoError(t, err, "loading stack") names := sf.Stacks[0].BranchNames() - if len(names) != 3 { - t.Fatalf("got %d branches, want 3: %v", len(names), names) - } - for i, want := range []string{"b1", "b2", "b3"} { - if names[i] != want { - t.Errorf("branch[%d] = %q, want %q", i, names[i], want) - } - } + assert.Equal(t, []string{"b1", "b2", "b3"}, names) } diff --git a/cmd/merge_test.go b/cmd/merge_test.go index 5b154ee..e065ebe 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -178,7 +178,6 @@ func TestMerge_NonInteractive_PrintsURL(t *testing.T) { output := string(errOut) assert.NoError(t, err) - assert.Contains(t, output, "not yet supported") assert.Contains(t, output, "https://github.com/owner/repo/pull/42") } @@ -241,7 +240,6 @@ func TestMerge_ByPRNumber(t *testing.T) { output := string(errOut) assert.NoError(t, err) - assert.Contains(t, output, "not yet supported") assert.Contains(t, output, "https://github.com/owner/repo/pull/42") } @@ -274,7 +272,6 @@ func TestMerge_ByPRURL(t *testing.T) { output := string(errOut) assert.NoError(t, err) - assert.Contains(t, output, "not yet supported") assert.Contains(t, output, "https://github.com/owner/repo/pull/42") } @@ -307,6 +304,5 @@ func TestMerge_ByBranchName(t *testing.T) { output := string(errOut) assert.NoError(t, err) - assert.Contains(t, output, "not yet supported") assert.Contains(t, output, "https://github.com/owner/repo/pull/42") } diff --git a/cmd/navigate_test.go b/cmd/navigate_test.go index 0158f8e..90feefb 100644 --- a/cmd/navigate_test.go +++ b/cmd/navigate_test.go @@ -363,6 +363,45 @@ func TestNavigate_BottomWithMergedFirst(t *testing.T) { assert.Equal(t, []string{"b2"}, checkedOut, "should skip merged b1") } +func TestNavigate_AllMerged_Up(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}}, + }, + } + + 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, outR, errR := config.NewTestConfig() + cmd := UpCmd(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 when already at top of all-merged stack") + assert.Contains(t, output, "Already at the top") + // On a merged branch, navigate prints a warning before the at-top message + assert.Contains(t, output, "you are on merged branch") +} + // writeStackFile is a helper to write a stack file to a temp dir. func writeStackFile(t *testing.T, dir string, s stack.Stack) { t.Helper() diff --git a/cmd/push_test.go b/cmd/push_test.go index a82fdae..93f8cce 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -1,9 +1,15 @@ 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" + "github.com/stretchr/testify/require" ) func TestGeneratePRBody(t *testing.T) { @@ -41,3 +47,177 @@ func TestGeneratePRBody(t *testing.T) { }) } } + +// newPushMock creates a MockOps pre-configured for push tests. +func newPushMock(tmpDir string, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + PushFn: func(string, []string, bool, bool) error { return nil }, + } +} + +func TestPush_SkipPRs(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) + + var pushCalls []pushCall + + mock := newPushMock(tmpDir, "b1") + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := PushCmd(cfg) + cmd.SetArgs([]string{"--skip-prs"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + require.Len(t, pushCalls, 1) + assert.Equal(t, "origin", pushCalls[0].remote) + assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches) + assert.True(t, pushCalls[0].force) + assert.True(t, pushCalls[0].atomic) +} + +func TestPush_SkipsMergedBranches(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"}, + {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3, Merged: true}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var pushCalls []pushCall + + mock := newPushMock(tmpDir, "b2") + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := PushCmd(cfg) + cmd.SetArgs([]string{"--skip-prs"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + require.Len(t, pushCalls, 1) + assert.Equal(t, []string{"b2"}, pushCalls[0].branches) +} + +func TestPush_PushFailure(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newPushMock(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { + return fmt.Errorf("remote rejected") + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := PushCmd(cfg) + cmd.SetArgs([]string{"--skip-prs"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, "failed to push") +} + +func TestPush_DefaultPRTitleBody(t *testing.T) { + t.Run("single_commit", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{ + {Subject: "Add login page", Body: "Implements the OAuth flow"}, + }, nil + }, + }) + defer restore() + + title, body := defaultPRTitleBody("main", "feat-login") + assert.Equal(t, "Add login page", title) + assert.Equal(t, "Implements the OAuth flow", body) + }) + + t.Run("multiple_commits", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{ + {Subject: "First commit"}, + {Subject: "Second commit"}, + }, nil + }, + }) + defer restore() + + title, body := defaultPRTitleBody("main", "my-feature") + assert.Equal(t, "my feature", title) + assert.Equal(t, "", body) + }) +} + +func TestPush_Humanize(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"my-branch", "my branch"}, + {"my_branch", "my branch"}, + {"nobranch", "nobranch"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, humanize(tt.input)) + }) + } +} diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 2288b35..1b70db0 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -45,9 +45,6 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { // TestRebase_CascadeRebase verifies that a stack [b1, b2, b3] with all active // branches triggers the correct cascade: b1 rebased onto trunk, b2 onto b1, // b3 onto b2. -// -// Per the code: branch at index 0 uses git.Rebase(trunk), subsequent branches -// use git.RebaseOnto(base, originalRefs[base], branch). func TestRebase_CascadeRebase(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, @@ -61,21 +58,20 @@ func TestRebase_CascadeRebase(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - var rebaseCalls []rebaseCall - var checkouts []string - var plainRebaseCalls []string + var allRebaseCalls []rebaseCall + var currentCheckedOut string mock := newRebaseMock(tmpDir, "b2") mock.CheckoutBranchFn = func(name string) error { - checkouts = append(checkouts, name) + currentCheckedOut = name return nil } mock.RebaseFn = func(base string) error { - plainRebaseCalls = append(plainRebaseCalls, base) + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) return nil } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { - rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -94,15 +90,11 @@ func TestRebase_CascadeRebase(t *testing.T) { assert.NoError(t, err) - // Branch 0 (b1): checkout b1, then Rebase("main") - assert.Contains(t, checkouts, "b1", "b1 should be checked out for plain rebase") - require.Len(t, plainRebaseCalls, 1, "exactly one plain rebase call expected (for b1)") - assert.Equal(t, "main", plainRebaseCalls[0]) - - // Branches 1,2 (b2, b3): RebaseOnto(base, originalRefs[base], branch) - require.Len(t, rebaseCalls, 2, "two RebaseOnto calls expected (for b2, b3)") - assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0]) - assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1]) + // All branches should be rebased in order: b1 onto main, b2 onto b1, b3 onto b2 + require.Len(t, allRebaseCalls, 3) + assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk") + assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1") + assert.Equal(t, "b2", allRebaseCalls[2].newBase, "b3 should be rebased onto b2") assert.Contains(t, output, "rebased locally") } @@ -125,7 +117,21 @@ func TestRebase_SquashMergedBranch_UsesOnto(t *testing.T) { var rebaseCalls []rebaseCall + // Use explicit SHAs so assertions are self-documenting + branchSHAs := map[string]string{ + "main": "main-sha-aaa", + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + } + mock := newRebaseMock(tmpDir, "b2") + mock.RevParseFn = func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil @@ -150,9 +156,9 @@ func TestRebase_SquashMergedBranch_UsesOnto(t *testing.T) { // b2: onto trunk, oldBase = b1's original SHA // b3: onto b2, oldBase = b2's original SHA (propagation) require.Len(t, rebaseCalls, 2) - assert.Equal(t, rebaseCall{"main", "sha-b1", "b2"}, rebaseCalls[0], + assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseCalls[0], "b2 should rebase --onto main using b1's original SHA as oldBase") - assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1], + assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[1], "b3 should propagate --onto mode with b2's original SHA as oldBase") } @@ -174,7 +180,22 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { var rebaseCalls []rebaseCall + // Use explicit SHAs so assertions are self-documenting + branchSHAs := map[string]string{ + "main": "main-sha-aaa", + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + "b4": "b4-orig-sha", + } + mock := newRebaseMock(tmpDir, "b3") + mock.RevParseFn = func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil @@ -197,16 +218,16 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { assert.Contains(t, output, "Skipping b1") assert.Contains(t, output, "Skipping b2") - // b1 merged → ontoOldBase = sha-b1 - // b2 merged → ontoOldBase = sha-b2 + // b1 merged → ontoOldBase = b1-orig-sha + // b2 merged → ontoOldBase = b2-orig-sha // b3: first non-merged ancestor search finds none → newBase = trunk - // RebaseOnto("main", "sha-b2", "b3") + // RebaseOnto("main", "b2-orig-sha", "b3") // b4: first non-merged ancestor = b3 → newBase = b3 - // RebaseOnto("b3", "sha-b3", "b4") + // RebaseOnto("b3", "b3-orig-sha", "b4") require.Len(t, rebaseCalls, 2) - assert.Equal(t, rebaseCall{"main", "sha-b2", "b3"}, rebaseCalls[0], + assert.Equal(t, rebaseCall{"main", "b2-orig-sha", "b3"}, rebaseCalls[0], "b3 should rebase --onto main with b2's SHA as oldBase") - assert.Equal(t, rebaseCall{"b3", "sha-b3", "b4"}, rebaseCalls[1], + assert.Equal(t, rebaseCall{"b3", "b3-orig-sha", "b4"}, rebaseCalls[1], "b4 should rebase --onto b3 with b3's original SHA as oldBase") } @@ -376,16 +397,20 @@ func TestRebase_DownstackOnly(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - var rebasedBranches []string + var allRebaseCalls []rebaseCall + var currentCheckedOut string mock := newRebaseMock(tmpDir, "b2") - mock.CheckoutBranchFn = func(string) error { return nil } + mock.CheckoutBranchFn = func(name string) error { + currentCheckedOut = name + return nil + } mock.RebaseFn = func(base string) error { - // This is called for b1 (index 0) + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) return nil } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { - rebasedBranches = append(rebasedBranches, branch) + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -404,11 +429,9 @@ func TestRebase_DownstackOnly(t *testing.T) { assert.NoError(t, err) // b2 is at index 1, so downstack = [b1, b2] (indices 0..1) - // b1 uses plain Rebase (not tracked here), b2 uses RebaseOnto - assert.Equal(t, []string{"b2"}, rebasedBranches, - "only b2 should use RebaseOnto in downstack mode") - assert.NotContains(t, rebasedBranches, "b3", - "b3 should NOT be rebased in downstack mode") + require.Len(t, allRebaseCalls, 2, "downstack should rebase b1 and b2 only") + assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk") + assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1") } // TestRebase_UpstackOnly verifies that --upstack only rebases branches @@ -426,12 +449,20 @@ func TestRebase_UpstackOnly(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - var rebasedBranches []string + var allRebaseCalls []rebaseCall + var currentCheckedOut string mock := newRebaseMock(tmpDir, "b2") - mock.CheckoutBranchFn = func(string) error { return nil } + mock.CheckoutBranchFn = func(name string) error { + currentCheckedOut = name + return nil + } + mock.RebaseFn = func(base string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) + return nil + } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { - rebasedBranches = append(rebasedBranches, branch) + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -449,10 +480,10 @@ func TestRebase_UpstackOnly(t *testing.T) { cfg.Err.Close() assert.NoError(t, err) - // b2 is at index 1, upstack starts at index 1. - // b2 at absIdx=1 uses RebaseOnto(b1, sha-b1, b2), b3 at absIdx=2 uses RebaseOnto(b2, sha-b2, b3) - assert.Equal(t, []string{"b2", "b3"}, rebasedBranches, - "upstack should rebase b2 and b3") + // b2 is at index 1, upstack = [b2, b3] (indices 1..2) + require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3") + assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1") + assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2") } // TestRebase_SkipsMergedBranches verifies that merged branches are skipped @@ -533,3 +564,287 @@ func TestRebase_StateRoundTrip(t *testing.T) { assert.Equal(t, original.UseOnto, loaded.UseOnto) assert.Equal(t, original.OntoOldBase, loaded.OntoOldBase) } + +// TestRebase_Continue_RebasesRemainingBranches verifies the --continue success +// path: RebaseContinue is called, remaining branches are rebased via RebaseOnto, +// the state file is cleaned up, and the original branch is restored. +func TestRebase_Continue_RebasesRemainingBranches(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) + + // State: b2 had a conflict (index 1), b3 remains to be rebased. + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "main": "main-orig-sha", + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseContinueCalled bool + var rebaseCalls []rebaseCall + var checkouts []string + + mock := newRebaseMock(tmpDir, "b2") + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { + rebaseContinueCalled = true + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.True(t, rebaseContinueCalled, "RebaseContinue should be called") + + // b3 is at idx 2 (idx > 0, not UseOnto) → RebaseOnto(base=b2, originalRefs[b2], b3) + require.Len(t, rebaseCalls, 1) + assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[0]) + + // State file should be removed after success + _, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(statErr), "state file should be removed after success") + + // Original branch should be checked out at the end + assert.Contains(t, checkouts, "b1", "should checkout original branch") +} + +// TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is +// set (squash-merged branches upstream). With no remaining branches, only +// RebaseContinue runs and the state is cleaned up. +func TestRebase_Continue_OntoMode(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11, Merged: true}}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // b3 was the conflict branch; no remaining branches after it. + state := &rebaseState{ + CurrentBranchIndex: 2, + ConflictBranch: "b3", + RemainingBranches: []string{}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "main": "sha-main", + "b1": "sha-b1", + "b2": "sha-b2", + "b3": "sha-b3", + }, + UseOnto: true, + OntoOldBase: "sha-b2", + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseContinueCalled bool + + mock := newRebaseMock(tmpDir, "b3") + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { + rebaseContinueCalled = true + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.True(t, rebaseContinueCalled, "RebaseContinue should be called") + + // State file should be removed after success + _, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(statErr), "state file should be removed after success") +} + +// TestRebase_Continue_ConflictOnRemaining verifies that when --continue +// successfully resolves the first conflict but hits a new conflict on a +// remaining branch, the state is updated and ErrConflict is returned. +func TestRebase_Continue_ConflictOnRemaining(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + {Branch: "b4"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3", "b4"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "main": "sha-main", + "b1": "sha-b1", + "b2": "sha-b2", + "b3": "sha-b3", + "b4": "sha-b4", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + mock := newRebaseMock(tmpDir, "b2") + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "b3" { + return assert.AnError // conflict on b3 + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil } + mock.CheckoutBranchFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.Error(t, err) + assert.ErrorIs(t, err, ErrConflict) + assert.Contains(t, output, "--continue") + + // State file should still exist with updated conflict info + updatedData, readErr := os.ReadFile(filepath.Join(tmpDir, "gh-stack-rebase-state")) + require.NoError(t, readErr, "state file should still exist after new conflict") + + var updatedState rebaseState + require.NoError(t, json.Unmarshal(updatedData, &updatedState)) + assert.Equal(t, "b3", updatedState.ConflictBranch) + assert.Equal(t, []string{"b4"}, updatedState.RemainingBranches) +} + +// TestRebase_Abort_WithActiveRebase verifies that --abort calls RebaseAbort +// when a git rebase is in progress, restores branches, and cleans up the state. +func TestRebase_Abort_WithActiveRebase(t *testing.T) { + tmpDir := t.TempDir() + + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "b1": "orig-sha-b1", + "b2": "orig-sha-b2", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseAbortCalled bool + var resets []resetCall + var checkouts []string + currentBranch := "b2" + + mock := newRebaseMock(tmpDir, currentBranch) + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseAbortFn = func() error { + rebaseAbortCalled = true + return nil + } + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + currentBranch = name + return nil + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--abort"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.True(t, rebaseAbortCalled, "RebaseAbort should be called when rebase is in progress") + assert.Contains(t, output, "Rebase aborted and branches restored") + + // Verify branches restored to original SHAs + resetMap := make(map[string]string) + for _, r := range resets { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "orig-sha-b1", resetMap["b1"]) + assert.Equal(t, "orig-sha-b2", resetMap["b2"]) + + // State file should be removed + _, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(statErr), "state file should be removed after abort") + + // Should return to original branch + assert.Contains(t, checkouts, "b1", "should checkout original branch at end") +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 8ac3626..a842ae4 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -54,10 +54,10 @@ func TestSync_TrunkAlreadyUpToDate(t *testing.T) { var pushCalls []pushCall mock := newSyncMock(tmpDir, "b1") - // Same SHA for trunk and origin/trunk → already up to date + // Use same explicit SHA for local and remote trunk — already up to date mock.RevParseFn = func(ref string) (string, error) { - if ref == "origin/main" { - return "sha-main", nil // same as local trunk + if ref == "main" || ref == "origin/main" { + return "aaa111aaa111", nil } return "sha-" + ref, nil } @@ -507,3 +507,143 @@ func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { }) } } + +// TestSync_SquashMergedBranch_UsesOnto verifies that when a squash-merged +// branch exists in the stack, sync's cascade rebase correctly uses --onto +// to skip the merged branch and rebase subsequent branches onto the right base. +func TestSync_SquashMergedBranch_UsesOnto(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"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseOntoCalls []rebaseCall + var pushCalls []pushCall + + // Use explicit SHAs so assertions are self-documenting + branchSHAs := map[string]string{ + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + } + + mock := newSyncMock(tmpDir, "b2") + // Trunk behind remote to trigger rebase + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + + // b1 is merged → skipped, needsOnto=true, ontoOldBase=b1-orig-sha + // b2: first active branch after merged → RebaseOnto(main, b1-orig-sha, b2) + // b3: normal --onto → RebaseOnto(b2, b2-orig-sha, b3) + require.Len(t, rebaseOntoCalls, 2) + assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseOntoCalls[0]) + assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseOntoCalls[1]) + + // Push should use force (rebase happened) + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force) +} + +// TestSync_PushFailureAfterRebase verifies that when push fails after a +// successful rebase, the command does not return a fatal error — only a +// warning is printed about the push failure. +func TestSync_PushFailureAfterRebase(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) + + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + // Trunk behind remote → triggers rebase + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + mock.RebaseOntoFn = func(string, string, string) error { return nil } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return fmt.Errorf("network error: connection refused") + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + // Push failures are warnings, not fatal errors. + assert.NoError(t, err) + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force, "push after rebase should use force") + assert.Contains(t, output, "Push failed") +} diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go new file mode 100644 index 0000000..0939a5b --- /dev/null +++ b/cmd/unstack_test.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "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" + "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{ + 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{}) + output := collectOutput(cfg, outR, errR) + + // The GitHub API call will fail (no real repo), but the command should not + // return a fatal error — only a warning is printed. + 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) + 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 error message should NOT appear. + assert.NotContains(t, output, "failed to create GitHub client") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) +} + +func TestUnstack_WithTarget(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "unrelated", 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{target: "b3", local: true}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed") + + 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()) +} diff --git a/cmd/view_test.go b/cmd/view_test.go index efe9f54..865484d 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -204,3 +204,82 @@ func TestViewJSON_BranchFields(t *testing.T) { 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") +} + +// 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") +} From 0869244c1257cfc12c159618b3f4f23c063f0673 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 17:08:54 -0400 Subject: [PATCH 72/78] fix for help output properly showing "gh stack" --- cmd/root.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 0cd60b7..0fd155b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -53,7 +53,13 @@ func RootCmd() *cobra.Command { func Execute() { cmd := RootCmd() - if err := cmd.Execute(); err != nil { + + // Wrap in a "gh" parent so help output shows "gh stack" instead of just "stack". + wrapCmd := &cobra.Command{Use: "gh", SilenceUsage: true, SilenceErrors: true} + wrapCmd.AddCommand(cmd) + wrapCmd.SetArgs(append([]string{"stack"}, os.Args[1:]...)) + + if err := wrapCmd.Execute(); err != nil { var exitErr *ExitError if errors.As(err, &exitErr) { os.Exit(exitErr.Code) From b99f7257e087f40a7e9bb549c3b67d4953d08e1a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 17:29:39 -0400 Subject: [PATCH 73/78] set release version via ldflags --- .github/workflows/release.yml | 4 ++++ cmd/root.go | 2 ++ cmd/version.go | 8 ++++---- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b13330..f67c8b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - uses: cli/gh-extension-precompile@v2 with: generate_attestations: true go_version_file: go.mod + go_build_options: >- + -ldflags '-X github.com/github/gh-stack/cmd.Version=${{ steps.version.outputs.version }}' diff --git a/cmd/root.go b/cmd/root.go index 0fd155b..db872f4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,8 @@ func RootCmd() *cobra.Command { SilenceErrors: true, } + root.SetVersionTemplate("gh stack version {{.Version}}\n") + root.SetOut(cfg.Out) root.SetErr(cfg.Err) diff --git a/cmd/version.go b/cmd/version.go index 270f51f..2959bf1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,7 +1,7 @@ package cmd // Version is the current version of gh-stack. -// It can be overridden at build time via: -// -// go build -ldflags="-X github.com/github/gh-stack/cmd.Version=1.2.3" -var Version = "0.0.1" +// In release builds, this is overridden at build time via ldflags +// (see .github/workflows/release.yml). +// The "dev" default indicates a local development build. +var Version = "dev" From b53bc7ad6939f024ca5f827aaec48f399e55cc8f Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 17:54:56 -0400 Subject: [PATCH 74/78] mock gh api client for tests --- cmd/push_test.go | 4 ++++ cmd/unstack_test.go | 2 ++ internal/config/config.go | 9 ++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/push_test.go b/cmd/push_test.go index 93f8cce..8a38a8d 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -82,6 +83,7 @@ func TestPush_SkipPRs(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetArgs([]string{"--skip-prs"}) cmd.SetOut(io.Discard) @@ -124,6 +126,7 @@ func TestPush_SkipsMergedBranches(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetArgs([]string{"--skip-prs"}) cmd.SetOut(io.Discard) @@ -158,6 +161,7 @@ func TestPush_PushFailure(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetArgs([]string{"--skip-prs"}) cmd.SetOut(io.Discard) diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 0939a5b..6087887 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -43,6 +44,7 @@ func TestUnstack_RemovesStack(t *testing.T) { writeTwoStacks(t, gitDir, s1, s2) cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} err := runUnstack(cfg, &unstackOptions{}) output := collectOutput(cfg, outR, errR) diff --git a/internal/config/config.go b/internal/config/config.go index e3fc5ce..27a0f4a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,10 @@ type Config struct { ColorMagenta func(string) string ColorCyan func(string) string ColorGray func(string) string + + // GitHubClientOverride, when non-nil, is returned by GitHubClient() + // instead of creating a real client. Used in tests to inject a MockClient. + GitHubClientOverride ghapi.ClientOps } // New creates a new Config with terminal-aware output and color support. @@ -109,7 +113,10 @@ func (c *Config) Repo() (repository.Repository, error) { return repository.Current() } -func (c *Config) GitHubClient() (*ghapi.Client, error) { +func (c *Config) GitHubClient() (ghapi.ClientOps, error) { + if c.GitHubClientOverride != nil { + return c.GitHubClientOverride, nil + } repo, err := c.Repo() if err != nil { return nil, fmt.Errorf("determining repository: %w", err) From 0c088446ede1ff821662a5a2f6370c930ddfffd3 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 17:57:45 -0400 Subject: [PATCH 75/78] clean up go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2002ae0..2084dab 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/github/gh-stack go 1.25.7 require ( + github.com/AlecAivazis/survey/v2 v2.3.7 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 @@ -16,7 +17,6 @@ require ( ) require ( - github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.10.2 // indirect From d5fcf124159ae0329c7f146d7820c3ca55b42821 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 23:08:50 -0400 Subject: [PATCH 76/78] update gh-stack file schema --- internal/stack/schema.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/stack/schema.json b/internal/stack/schema.json index c59b241..3e9f2ab 100644 --- a/internal/stack/schema.json +++ b/internal/stack/schema.json @@ -30,6 +30,14 @@ "type": "string", "description": "Identifier for this stack, populated from the API when available." }, + "prefix": { + "type": "string", + "description": "Branch name prefix for the stack (e.g. 'myfeature')." + }, + "numbered": { + "type": "boolean", + "description": "Whether to use auto-incrementing numbered branch names." + }, "trunk": { "$ref": "#/$defs/branchRef", "description": "The trunk (base) branch of the stack." From b2ba2c224eed9d30429b4187b7811bea7e404b86 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 23:09:24 -0400 Subject: [PATCH 77/78] throw error for non-numeric args --- cmd/navigate.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cmd/navigate.go b/cmd/navigate.go index 211184c..aad9c7b 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -1,7 +1,7 @@ package cmd import ( - "fmt" + "strconv" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -16,7 +16,12 @@ func UpCmd(cfg *config.Config) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { n := 1 if len(args) > 0 { - fmt.Sscanf(args[0], "%d", &n) + var err error + n, err = strconv.Atoi(args[0]) + if err != nil { + cfg.Errorf("invalid number %q", args[0]) + return ErrInvalidArgs + } } return runNavigate(cfg, n) }, @@ -31,7 +36,12 @@ func DownCmd(cfg *config.Config) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { n := 1 if len(args) > 0 { - fmt.Sscanf(args[0], "%d", &n) + var err error + n, err = strconv.Atoi(args[0]) + if err != nil { + cfg.Errorf("invalid number %q", args[0]) + return ErrInvalidArgs + } } return runNavigate(cfg, -n) }, From c26f8b0dfa01660e72868844064811d6c9d69d33 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 24 Mar 2026 23:10:04 -0400 Subject: [PATCH 78/78] use correct var in error msg --- cmd/merge.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/merge.go b/cmd/merge.go index bb9859e..74c452d 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -68,7 +68,7 @@ func runMerge(cfg *config.Config, target string) error { } if br.PullRequest == nil { - cfg.Errorf("no pull request found for branch %q", currentBranch) + cfg.Errorf("no pull request found for branch %q", br.Branch) cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack push")) return ErrSilent }