diff --git a/cmd/cascade.go b/cmd/cascade.go index 5c73127..8b98f76 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -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 @@ -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 diff --git a/cmd/continue.go b/cmd/continue.go index d7e27ef..26f232d 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -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 { @@ -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!") diff --git a/cmd/log.go b/cmd/log.go index b9cc3bf..77cdc97 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -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" ) @@ -67,34 +70,75 @@ func runLog(cmd *cobra.Command, args []string) error { return nil } -// printPorcelain outputs machine-readable tab-separated format: -// BRANCHPARENTPR_NUMBERIS_CURRENTPR_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) + } } diff --git a/cmd/submit.go b/cmd/submit.go index 843664c..576c971 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -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" ) @@ -35,6 +36,7 @@ var ( submitCurrentOnlyFlag bool submitUpdateOnlyFlag bool submitYesFlag bool + submitWebFlag bool ) func init() { @@ -42,6 +44,7 @@ func init() { 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) } @@ -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 # 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 @@ -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 { @@ -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() @@ -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 == "" { @@ -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 { @@ -188,8 +213,14 @@ 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 { @@ -197,6 +228,16 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr } } + // 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 } diff --git a/e2e/submit_test.go b/e2e/submit_test.go index 32ffa19..402af1e 100644 --- a/e2e/submit_test.go +++ b/e2e/submit_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod index f7e4d1a..b532c8e 100644 --- a/go.mod +++ b/go.mod @@ -9,17 +9,34 @@ 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.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // 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/shurcooL-graphql v0.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/henvic/httpretty v0.0.6 // 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/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.7.0 // indirect github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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 diff --git a/go.sum b/go.sum index f34f9b6..b695621 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,23 @@ +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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= +github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +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/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= 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= @@ -10,27 +26,51 @@ github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJ 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 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= +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.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +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.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +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/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/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/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= @@ -40,23 +80,56 @@ 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/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.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/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/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-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.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= +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.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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/prompt/prompt.go b/internal/prompt/prompt.go index 606ec54..16f8910 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -2,81 +2,64 @@ package prompt import ( - "bufio" "fmt" "os" "os/exec" "strings" + "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/cli/go-gh/v2/pkg/term" "github.com/cli/safeexec" - "github.com/mattn/go-isatty" ) -// IsInteractive returns true if stdin is connected to a terminal. +// termState caches terminal state for the session. +var termState term.Term + +func init() { + termState = term.FromEnv() +} + +// IsInteractive returns true if stdout is connected to a terminal. +// Respects GH_FORCE_TTY, NO_COLOR, CLICOLOR, and CLICOLOR_FORCE environment variables +// for consistency with the gh CLI. func IsInteractive() bool { - return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) + return termState.IsTerminalOutput() +} + +// newPrompter creates a prompter instance for interactive input. +func newPrompter() *prompter.Prompter { + return prompter.New(os.Stdin, os.Stdout, os.Stderr) } // Input prompts the user for a single line of input with a default value. // If the user enters nothing (just presses Enter), the default is returned. -// If stdin is not a TTY, the default is returned without prompting. +// If not in an interactive terminal, the default is returned without prompting. func Input(prompt, defaultValue string) (string, error) { if !IsInteractive() { return defaultValue, nil } - if defaultValue != "" { - fmt.Printf("%s [%s]: ", prompt, defaultValue) - } else { - fmt.Printf("%s: ", prompt) - } - - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') + p := newPrompter() + result, err := p.Input(prompt, defaultValue) if err != nil { - return "", fmt.Errorf("failed to read input: %w", err) - } - - input = strings.TrimSpace(input) - if input == "" { - return defaultValue, nil + return defaultValue, fmt.Errorf("failed to read input: %w", err) } - return input, nil + return result, nil } // Confirm prompts the user for a yes/no confirmation. -// Returns the defaultValue if stdin is not a TTY. +// Returns the defaultValue if not in an interactive terminal. func Confirm(prompt string, defaultValue bool) (bool, error) { if !IsInteractive() { return defaultValue, nil } - defaultStr := "y/N" - if defaultValue { - defaultStr = "Y/n" - } - - fmt.Printf("%s [%s]: ", prompt, defaultStr) - - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') + p := newPrompter() + result, err := p.Confirm(prompt, defaultValue) if err != nil { return defaultValue, fmt.Errorf("failed to read input: %w", err) } - - input = strings.TrimSpace(strings.ToLower(input)) - if input == "" { - return defaultValue, nil - } - - switch input { - case "y", "yes": - return true, nil - case "n", "no": - return false, nil - default: - return defaultValue, nil - } + return result, nil } // EditInEditor opens the given text in the user's preferred editor and returns @@ -149,9 +132,9 @@ func EditInEditor(text string) (string, error) { return result, nil } -// Select prompts the user to choose from a list of options. +// Select prompts the user to choose from a list of options using arrow keys. // Returns the index of the selected option (0-based). -// If stdin is not a TTY, returns the defaultIndex. +// If not in an interactive terminal, returns the defaultIndex. func Select(prompt string, options []string, defaultIndex int) (int, error) { if len(options) == 0 { return 0, fmt.Errorf("no options provided") @@ -166,38 +149,13 @@ func Select(prompt string, options []string, defaultIndex int) (int, error) { return defaultIndex, nil } - fmt.Println(prompt) - for i, opt := range options { - marker := " " - if i == defaultIndex { - marker = "> " - } - fmt.Printf("%s%d. %s\n", marker, i+1, opt) - } - - fmt.Printf("Choice [%d]: ", defaultIndex+1) + // Convert defaultIndex to default value string for prompter + defaultValue := options[defaultIndex] - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') + p := newPrompter() + result, err := p.Select(prompt, defaultValue, options) if err != nil { - return defaultIndex, fmt.Errorf("failed to read input: %w", err) - } - - input = strings.TrimSpace(input) - if input == "" { - return defaultIndex, nil + return defaultIndex, fmt.Errorf("failed to read selection: %w", err) } - - var choice int - if _, err := fmt.Sscanf(input, "%d", &choice); err != nil { - return defaultIndex, nil - } - - // Convert to 0-based index - choice-- - if choice < 0 || choice >= len(options) { - return defaultIndex, nil - } - - return choice, nil + return result, nil } diff --git a/internal/state/state.go b/internal/state/state.go index 8fe1568..a87cad2 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -28,6 +28,8 @@ type CascadeState struct { Operation string `json:"operation,omitempty"` // UpdateOnly (submit only) - if true, don't create new PRs, only update existing UpdateOnly bool `json:"update_only,omitempty"` + // Web (submit only) - if true, open PRs in browser after creation/update + Web bool `json:"web,omitempty"` // Branches (submit only) - the complete list of branches being submitted. // Used to rebuild the full set for push/PR phases after cascade completes. Branches []string `json:"branches,omitempty"`