Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ func runCascade(cmd *cobra.Command, args []string) error {
}

func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool) error {
return doCascadeWithState(g, cfg, branches, dryRun, state.OperationCascade, false, nil)
return doCascadeWithState(g, cfg, branches, dryRun, state.OperationCascade, false, false, nil)
}

// doCascadeWithState performs cascade and saves state with the given operation type.
// allBranches is the complete list of branches for submit operations (used for push/PR after continue).
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly bool, allBranches []string) error {
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web bool, allBranches []string) error {
originalBranch, err := g.CurrentBranch()
if err != nil {
return err
Expand Down Expand Up @@ -171,6 +171,7 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
OriginalHead: originalHead,
Operation: operation,
UpdateOnly: updateOnly,
Web: web,
Branches: allBranches,
}
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually
Expand Down
4 changes: 2 additions & 2 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func runContinue(cmd *cobra.Command, args []string) error {
// Remove state file before continuing (will be recreated if conflict)
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup

if err := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Branches); err != nil {
if err := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.Branches); err != nil {
return err // Another conflict - state saved
}
} else {
Expand Down Expand Up @@ -104,7 +104,7 @@ func runContinue(cmd *cobra.Command, args []string) error {
allBranches = append(allBranches, node)
}

return doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly)
return doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web)
}

fmt.Println("Cascade complete!")
Expand Down
74 changes: 59 additions & 15 deletions cmd/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ package cmd
import (
"fmt"
"os"
"strconv"

"github.com/boneskull/gh-stack/internal/config"
"github.com/boneskull/gh-stack/internal/git"
"github.com/boneskull/gh-stack/internal/github"
"github.com/boneskull/gh-stack/internal/tree"
"github.com/cli/go-gh/v2/pkg/tableprinter"
"github.com/cli/go-gh/v2/pkg/term"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -67,34 +70,75 @@ func runLog(cmd *cobra.Command, args []string) error {
return nil
}

// printPorcelain outputs machine-readable tab-separated format:
// BRANCH<tab>PARENT<tab>PR_NUMBER<tab>IS_CURRENT<tab>PR_URL
// printPorcelain outputs stack information in table format.
// In TTY mode, outputs nicely formatted columns.
// In non-TTY mode (piped/scripted), outputs tab-separated values.
//
// Fields:
// - BRANCH: branch name
// Columns:
// - BRANCH: branch name (* prefix for current branch in TTY mode)
// - PARENT: parent branch name (empty for trunk)
// - PR_NUMBER: associated PR number (0 if none)
// - IS_CURRENT: "1" if current branch, "0" otherwise
// - PR_URL: full PR URL (empty if no PR or GitHub client unavailable)
// - PR: associated PR number (empty if none)
// - URL: full PR URL (empty if no PR or GitHub client unavailable)
func printPorcelain(node *tree.Node, current string, gh *github.Client) {
var printNode func(*tree.Node, int)
printNode = func(n *tree.Node, depth int) {
isCurrent := "0"
if n.Name == current {
isCurrent = "1"
t := term.FromEnv()
isTTY := t.IsTerminalOutput()

var width int
if isTTY {
w, _, err := t.Size()
if err != nil || w <= 0 {
width = 80 // reasonable default width for TTY when detection fails
} else {
width = w
}
} else {
// In non-TTY mode, tableprinter outputs TSV; use a large width to avoid truncation.
width = 4096
}

tp := tableprinter.New(os.Stdout, isTTY, width)

// Add headers in TTY mode
if isTTY {
tp.AddHeader([]string{"BRANCH", "PARENT", "PR", "URL"})
}

// Collect all nodes in tree order
var addNode func(*tree.Node)
addNode = func(n *tree.Node) {
branchName := n.Name
if isTTY && n.Name == current {
branchName = "* " + n.Name
}

parent := ""
if n.Parent != nil {
parent = n.Parent.Name
}

prNum := ""
if n.PR > 0 {
prNum = strconv.Itoa(n.PR)
}

prURL := ""
if n.PR > 0 && gh != nil {
prURL = gh.PRURL(n.PR)
}
fmt.Printf("%s\t%s\t%d\t%s\t%s\n", n.Name, parent, n.PR, isCurrent, prURL)

tp.AddField(branchName)
tp.AddField(parent)
tp.AddField(prNum)
tp.AddField(prURL)
tp.EndRow()

for _, child := range n.Children {
printNode(child, depth+1)
addNode(child)
}
}
printNode(node, 0)
addNode(node)

if err := tp.Render(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to render table: %v\n", err)
}
}
61 changes: 51 additions & 10 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/boneskull/gh-stack/internal/prompt"
"github.com/boneskull/gh-stack/internal/state"
"github.com/boneskull/gh-stack/internal/tree"
"github.com/cli/go-gh/v2/pkg/browser"
"github.com/spf13/cobra"
)

Expand All @@ -35,13 +36,15 @@ var (
submitCurrentOnlyFlag bool
submitUpdateOnlyFlag bool
submitYesFlag bool
submitWebFlag bool
)

func init() {
submitCmd.Flags().BoolVar(&submitDryRunFlag, "dry-run", false, "show what would be done without doing it")
submitCmd.Flags().BoolVar(&submitCurrentOnlyFlag, "current-only", false, "only submit current branch, not descendants")
submitCmd.Flags().BoolVar(&submitUpdateOnlyFlag, "update-only", false, "only update existing PRs, don't create new ones")
submitCmd.Flags().BoolVarP(&submitYesFlag, "yes", "y", false, "skip interactive prompts and use auto-generated title/description for PRs")
submitCmd.Flags().BoolVarP(&submitWebFlag, "web", "w", false, "open created/updated PRs in web browser")
rootCmd.AddCommand(submitCmd)
}

Expand Down Expand Up @@ -83,17 +86,33 @@ func runSubmit(cmd *cobra.Command, args []string) error {
return err
}

trunk, err := cfg.GetTrunk()
if err != nil {
return err
}

node := tree.FindNode(root, currentBranch)
if node == nil {
trunk, _ := cfg.GetTrunk() //nolint:errcheck // empty is fine for error message
return fmt.Errorf("branch %q is not tracked in the stack\n\nTo add it, run:\n gh stack adopt %s # to stack on %s\n gh stack adopt -p <parent> # to stack on a different branch", currentBranch, trunk, trunk)
}

// Collect branches to submit (current + descendants)
// Collect branches to submit (current + descendants, but never trunk)
var branches []*tree.Node
branches = append(branches, node)
if !submitCurrentOnlyFlag {
branches = append(branches, tree.GetDescendants(node)...)
if currentBranch == trunk {
// On trunk: only submit descendants, not trunk itself
if submitCurrentOnlyFlag {
return fmt.Errorf("cannot submit trunk branch %q; switch to a stack branch or remove --current-only", trunk)
}
branches = tree.GetDescendants(node)
if len(branches) == 0 {
return fmt.Errorf("no stack branches to submit; trunk %q has no descendants", trunk)
}
} else {
// On a stack branch: submit it and optionally its descendants
branches = append(branches, node)
if !submitCurrentOnlyFlag {
branches = append(branches, tree.GetDescendants(node)...)
}
}

// Build the complete branch name list for state persistence
Expand All @@ -104,17 +123,17 @@ func runSubmit(cmd *cobra.Command, args []string) error {

// Phase 1: Cascade
fmt.Println("=== Phase 1: Cascade ===")
if err := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, branchNames); err != nil {
if err := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, branchNames); err != nil {
return err // Conflict or error - state saved, user can continue
}

// Phases 2 & 3
return doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag)
return doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag)
}

// doSubmitPushAndPR handles push and PR creation/update phases.
// This is called after cascade succeeds (or from continue after conflict resolution).
func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly bool) error {
func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb bool) error {
// Phase 2: Push all branches
fmt.Println("\n=== Phase 2: Push ===")
for _, b := range branches {
Expand All @@ -131,11 +150,11 @@ func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches
}

// Phase 3: Create/update PRs
return doSubmitPRs(g, cfg, root, branches, dryRun, updateOnly)
return doSubmitPRs(g, cfg, root, branches, dryRun, updateOnly, openWeb)
}

// doSubmitPRs handles PR creation/update for all branches.
func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly bool) error {
func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb bool) error {
fmt.Println("\n=== Phase 3: PRs ===")

trunk, err := cfg.GetTrunk()
Expand All @@ -153,6 +172,9 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr
}
}

// Collect PR URLs for --web flag
var prURLs []string

for _, b := range branches {
parent, _ := cfg.GetParent(b.Name) //nolint:errcheck // empty is fine
if parent == "" {
Expand All @@ -172,6 +194,9 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr
fmt.Printf("Warning: failed to update PR #%d base: %v\n", existingPR, err)
} else {
fmt.Println("ok")
if openWeb {
prURLs = append(prURLs, ghClient.PRURL(existingPR))
}
}
// Update stack comment
if err := ghClient.GenerateAndPostStackComment(root, b.Name, trunk, existingPR); err != nil {
Expand All @@ -188,15 +213,31 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr
fmt.Printf("Warning: failed to create PR for %s: %v\n", b.Name, err)
} else if adopted {
fmt.Printf("Adopted PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum))
if openWeb {
prURLs = append(prURLs, ghClient.PRURL(prNum))
}
} else {
fmt.Printf("Created PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum))
if openWeb {
prURLs = append(prURLs, ghClient.PRURL(prNum))
}
}
}
} else {
fmt.Printf("Skipping %s (no existing PR, --update-only)\n", b.Name)
}
}

// Open PRs in browser if requested
if openWeb && len(prURLs) > 0 {
b := browser.New("", os.Stdout, os.Stderr)
for _, url := range prURLs {
if err := b.Browse(url); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not open browser for %s: %v\n", url, err)
}
}
}

return nil
}

Expand Down
69 changes: 69 additions & 0 deletions e2e/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,72 @@ func TestSubmitRejectsUntrackedBranch(t *testing.T) {
t.Errorf("expected error about untracked branch, got: %s", result.Stderr)
}
}

func TestSubmitFromTrunkWithDescendants(t *testing.T) {
env := NewTestEnvWithRemote(t)
env.MustRun("init")

// Create a branch off trunk
env.MustRun("create", "feature-1")
env.CreateCommit("feature 1 work")

// Go back to trunk
env.Git("checkout", "main")

// Submit from trunk should process only descendants, not trunk itself
result := env.MustRun("submit", "--dry-run")

// Should NOT mention pushing main
if strings.Contains(result.Stdout, "Would push main") {
t.Error("should not push trunk branch")
}
// Should NOT mention creating PR for main
if strings.Contains(result.Stdout, "Would create PR for main") {
t.Error("should not create PR for trunk branch")
}
// Should mention feature-1
if !strings.Contains(result.Stdout, "Would push feature-1") {
t.Error("expected feature-1 in push output")
}
if !strings.Contains(result.Stdout, "Would create PR for feature-1") {
t.Error("expected PR creation for feature-1")
}
}

func TestSubmitFromTrunkCurrentOnlyFails(t *testing.T) {
env := NewTestEnvWithRemote(t)
env.MustRun("init")

// Create a branch so stack is not empty
env.MustRun("create", "feature-1")
env.CreateCommit("feature 1 work")

// Go back to trunk
env.Git("checkout", "main")

// Submit --current-only from trunk should fail
result := env.Run("submit", "--dry-run", "--current-only")

if result.Success() {
t.Error("expected submit --current-only from trunk to fail")
}
if !strings.Contains(result.Stderr, "cannot submit trunk") {
t.Errorf("expected error about trunk, got: %s", result.Stderr)
}
}

func TestSubmitFromTrunkNoDescendantsFails(t *testing.T) {
env := NewTestEnvWithRemote(t)
env.MustRun("init")

// Stay on trunk with no stack branches
// Submit should fail - nothing to do
result := env.Run("submit", "--dry-run")

if result.Success() {
t.Error("expected submit from trunk with no descendants to fail")
}
if !strings.Contains(result.Stderr, "no stack branches") {
t.Errorf("expected error about no stack branches, got: %s", result.Stderr)
}
}
Loading
Loading