diff --git a/README.md b/README.md index 99b15a4..b5c1fa9 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,16 @@ By default, `init` auto-detects the trunk branch (`main` or `master`). If neithe Display the branch tree showing the stack hierarchy, current branch, and associated PR numbers. +By default, `log` also shows untracked local branches in the tree if it can detect their likely parent via merge-base analysis. These "detected" branches are annotated but not persisted. Use `--no-detect` to disable this. + +Detection is automatically skipped in `--porcelain` mode since the porcelain format has no column to distinguish detected branches from tracked ones. + #### log Flags -| Flag | Description | -| ------------- | ------------------------------------- | -| `--all` | Show all branches | -| `--porcelain` | Machine-readable tab-separated output | +| Flag | Description | +| --------------- | ---------------------------------------------- | +| `--porcelain` | Machine-readable tab-separated output | +| `--no-detect` | Skip auto-detection of untracked branches | #### Porcelain Format @@ -208,10 +212,12 @@ Start tracking an existing branch by setting its parent. By default, adopts the current branch. The parent must be either the trunk or another tracked branch. +When no parent is specified, `adopt` auto-detects the parent using PR base branch data (if available) and local merge-base analysis. If the result is ambiguous in interactive mode, you'll be prompted to choose; in non-interactive mode an error is returned. + #### adopt Usage ```bash -gh stack adopt +gh stack adopt [parent] ``` #### adopt Flags @@ -296,11 +302,12 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`. #### restack Flags -| Flag | Description | -| ------------- | -------------------------------------------------------- | -| `--only` | Only restack current branch, not descendants | -| `--dry-run` | Show what would be done | -| `--worktrees` | Rebase branches checked out in linked worktrees in-place | +| Flag | Description | +| --------------- | -------------------------------------------------------- | +| `--only` | Only restack current branch, not descendants | +| `--dry-run` | Show what would be done | +| `--worktrees` | Rebase branches checked out in linked worktrees in-place | +| `--no-detect` | Skip auto-detection and adoption of untracked branches | ### continue @@ -327,6 +334,7 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo | `--no-restack` | Skip restacking branches | | `--dry-run` | Show what would be done | | `--worktrees` | Rebase branches checked out in linked worktrees in-place | +| `--no-detect` | Skip auto-detection and adoption of untracked branches | ### undo diff --git a/cmd/adopt.go b/cmd/adopt.go index da8c49d..901dc44 100644 --- a/cmd/adopt.go +++ b/cmd/adopt.go @@ -7,19 +7,25 @@ import ( "os" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" + "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/style" - "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) var adoptCmd = &cobra.Command{ - Use: "adopt ", + Use: "adopt [parent]", Short: "Start tracking an existing branch", Long: `Start tracking an existing branch by setting its parent. +When no parent is specified, the parent is auto-detected using PR base branch +and merge-base analysis. If the result is ambiguous, you will be prompted to +choose (interactive) or an error is returned (non-interactive). + By default, adopts the current branch. Use --branch to specify a different branch.`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: runAdopt, } @@ -42,9 +48,7 @@ func runAdopt(cmd *cobra.Command, args []string) error { } g := git.New(cwd) - - // Parent is the required positional argument - parent := args[0] + s := style.New() // Determine branch to adopt (from flag or current branch) var branchName string @@ -73,25 +77,62 @@ func runAdopt(cmd *cobra.Command, args []string) error { return err } + var parent string + var detectedPRNumber int + + if len(args) > 0 { + // Explicit parent provided + parent = args[0] + } else { + // Auto-detect parent + tracked, listErr := cfg.ListTrackedBranches() + if listErr != nil { + return fmt.Errorf("list tracked branches: %w", listErr) + } + + // Try to get GitHub client (may fail if no auth -- that's ok) + gh, _ := github.NewClient() //nolint:errcheck // nil client is fine for local-only detection + + result, detectErr := detect.DetectParent(branchName, tracked, trunk, g, gh) + if detectErr != nil { + return fmt.Errorf("auto-detect parent: %w", detectErr) + } + + switch result.Confidence { + case detect.High, detect.Medium: + parent = result.Parent + fmt.Printf("%s Detected parent %s %s\n", + s.SuccessIcon(), s.Branch(parent), s.Muted("("+result.Confidence.String()+" confidence)")) + case detect.Ambiguous: + if len(result.Candidates) == 0 { + return fmt.Errorf("could not detect parent for %s; specify one explicitly", s.Branch(branchName)) + } + if !prompt.IsInteractive() { + return fmt.Errorf("ambiguous parent for %s (candidates: %v); specify one explicitly", + s.Branch(branchName), result.Candidates) + } + idx, promptErr := prompt.Select( + fmt.Sprintf("Multiple parent candidates for %s:", branchName), + result.Candidates, 0) + if promptErr != nil { + return fmt.Errorf("prompt: %w", promptErr) + } + parent = result.Candidates[idx] + } + + detectedPRNumber = result.PRNumber + } + if parent != trunk { if _, parentErr := cfg.GetParent(parent); parentErr != nil { return fmt.Errorf("parent %q is not tracked", parent) } } - // Check for cycles (branch can't be ancestor of parent) - root, err := tree.Build(cfg) - if err != nil { - return err - } - - parentNode := tree.FindNode(root, parent) - if parentNode != nil { - for _, ancestor := range tree.GetAncestors(parentNode) { - if ancestor.Name == branchName { - return errors.New("cannot adopt: would create a cycle") - } - } + // Check for cycles via config parent chain walk (catches cases the tree + // model misses when nodes with broken parent links are omitted). + if wouldCycle(cfg, branchName, parent) { + return errors.New("cannot adopt: would create a cycle") } // Set parent @@ -105,7 +146,11 @@ func runAdopt(cmd *cobra.Command, args []string) error { _ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort } - s := style.New() + // Store PR number if detected + if detectedPRNumber > 0 { + _ = cfg.SetPR(branchName, detectedPRNumber) //nolint:errcheck // best effort + } + fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent)) return nil } diff --git a/cmd/adopt_test.go b/cmd/adopt_test.go index 9f71a80..39d5509 100644 --- a/cmd/adopt_test.go +++ b/cmd/adopt_test.go @@ -2,13 +2,33 @@ package cmd_test import ( + "os" + "os/exec" + "path/filepath" "testing" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/tree" ) +// addCommit creates a file with the given content and commits it. +func addCommit(t *testing.T, dir, filename, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", filename, err) + } + cmd := exec.Command("git", "-C", dir, "add", ".") + if err := cmd.Run(); err != nil { + t.Fatalf("git add: %v", err) + } + cmd = exec.Command("git", "-C", dir, "commit", "-m", "add "+filename) + if err := cmd.Run(); err != nil { + t.Fatalf("git commit: %v", err) + } +} + func TestAdoptBranch(t *testing.T) { dir := setupTestRepo(t) @@ -148,3 +168,114 @@ func TestAdoptStoresForkPoint(t *testing.T) { t.Errorf("fork point = %s, want %s", storedFP, trunkTip) } } + +// TestAdoptAutoDetect exercises the full detection-to-adoption pipeline: +// detect parent, validate, set parent, store fork point. +func TestAdoptAutoDetect(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + cfg, err := config.Load(dir) + if err != nil { + t.Fatalf("Load config: %v", err) + } + + trunk, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + + err = cfg.SetTrunk(trunk) + if err != nil { + t.Fatalf("SetTrunk: %v", err) + } + + // Create tracked branch A + err = g.CreateAndCheckout("feature-a") + if err != nil { + t.Fatalf("CreateAndCheckout feature-a: %v", err) + } + addCommit(t, dir, "a.txt", "a") + + err = cfg.SetParent("feature-a", trunk) + if err != nil { + t.Fatalf("SetParent feature-a: %v", err) + } + + // Create untracked branch B off A + err = g.CreateAndCheckout("feature-b") + if err != nil { + t.Fatalf("CreateAndCheckout feature-b: %v", err) + } + addCommit(t, dir, "b.txt", "b") + + // feature-b should not be tracked yet + _, getErr := cfg.GetParent("feature-b") + if getErr == nil { + t.Fatal("feature-b should not be tracked yet") + } + + // Simulate what runAdopt does when no parent arg is given: + // 1. Detect parent + tracked, _ := cfg.ListTrackedBranches() + result, detectErr := detect.DetectParent("feature-b", tracked, trunk, g, nil) + if detectErr != nil { + t.Fatalf("detection failed: %v", detectErr) + } + if result.Confidence == detect.Ambiguous { + t.Fatal("expected non-ambiguous detection") + } + if result.Parent != "feature-a" { + t.Errorf("expected detected parent 'feature-a', got %q", result.Parent) + } + + // 2. Validate parent is tracked (same check as runAdopt) + if result.Parent != trunk { + if _, parentErr := cfg.GetParent(result.Parent); parentErr != nil { + t.Fatalf("detected parent %q is not tracked: %v", result.Parent, parentErr) + } + } + + // 3. Set parent (same as runAdopt) + err = cfg.SetParent("feature-b", result.Parent) + if err != nil { + t.Fatalf("SetParent failed: %v", err) + } + + // 4. Store fork point (same as runAdopt) + forkPoint, fpErr := g.GetMergeBase("feature-b", result.Parent) + if fpErr != nil { + t.Fatalf("GetMergeBase failed: %v", fpErr) + } + _ = cfg.SetForkPoint("feature-b", forkPoint) + + // Verify the full adoption persisted correctly + parent, err := cfg.GetParent("feature-b") + if err != nil { + t.Fatalf("feature-b should be tracked now: %v", err) + } + if parent != "feature-a" { + t.Errorf("expected parent 'feature-a', got %q", parent) + } + + storedFP, fpGetErr := cfg.GetForkPoint("feature-b") + if fpGetErr != nil { + t.Fatalf("GetForkPoint failed: %v", fpGetErr) + } + if storedFP != forkPoint { + t.Errorf("fork point mismatch: stored=%s, expected=%s", storedFP, forkPoint) + } + + // Verify tree now includes feature-b + root, buildErr := tree.Build(cfg) + if buildErr != nil { + t.Fatalf("Build failed: %v", buildErr) + } + nodeB := tree.FindNode(root, "feature-b") + if nodeB == nil { + t.Fatal("feature-b should appear in tree after adoption") + } + if nodeB.Parent.Name != "feature-a" { + t.Errorf("expected parent node 'feature-a', got %q", nodeB.Parent.Name) + } +} diff --git a/cmd/cascade.go b/cmd/cascade.go index e43a139..8957606 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -8,6 +8,7 @@ import ( "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/state" "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" @@ -30,12 +31,14 @@ var ( cascadeOnlyFlag bool cascadeDryRunFlag bool cascadeWorktreesFlag bool + cascadeNoDetectFlag bool ) func init() { cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only restack current branch, not descendants") cascadeCmd.Flags().BoolVar(&cascadeDryRunFlag, "dry-run", false, "show what would be done") cascadeCmd.Flags().BoolVar(&cascadeWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place") + cascadeCmd.Flags().BoolVar(&cascadeNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(cascadeCmd) } @@ -64,6 +67,14 @@ func runCascade(cmd *cobra.Command, args []string) error { return err } + // Auto-detect and adopt untracked branches + if !cascadeNoDetectFlag { + gh, _ := github.NewClient() //nolint:errcheck // nil is fine, skip PR detection + if adoptErr := autoDetectAndAdopt(cfg, g, gh, s); adoptErr != nil { + fmt.Printf("%s auto-detection: %v\n", s.WarningIcon(), adoptErr) + } + } + // Build tree root, err := tree.Build(cfg) if err != nil { diff --git a/cmd/detect_helpers.go b/cmd/detect_helpers.go new file mode 100644 index 0000000..6b4f7b7 --- /dev/null +++ b/cmd/detect_helpers.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "cmp" + "fmt" + "slices" + + "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" + "github.com/boneskull/gh-stack/internal/prompt" + "github.com/boneskull/gh-stack/internal/style" +) + +// autoDetectAndAdopt finds untracked branches and adopts them using full detection +// (PR + merge-base). Prompts for ambiguous cases in interactive mode. +func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *style.Style) error { + trunk, err := cfg.GetTrunk() + if err != nil { + return err + } + + tracked, err := cfg.ListTrackedBranches() + if err != nil { + return err + } + + candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) + if err != nil { + return err + } + + if len(candidates) == 0 { + return nil + } + + // Sort candidates by merge-base distance from trunk (ascending) so that + // branches closer to trunk (parents) are processed before their children. + // Without this, alphabetical ordering can cause a child to be adopted with + // the wrong parent when both it and its true parent are untracked. + sortCandidatesByTrunkDistance(candidates, trunk, g) + + // Loop until no progress: untracked chains (e.g., C based on untracked B) + // may require multiple passes since a branch can only be detected once its + // parent has been adopted into the tracked set. + adopted := make(map[string]bool) + for { + progress := false + for _, branch := range candidates { + if adopted[branch] { + continue + } + + result, detectErr := detect.DetectParent(branch, tracked, trunk, g, gh) + if detectErr != nil { + fmt.Printf("%s could not detect parent for %s: %v\n", + s.WarningIcon(), s.Branch(branch), detectErr) + continue + } + + var parent string + switch result.Confidence { + case detect.High, detect.Medium: + parent = result.Parent + case detect.Ambiguous: + if prompt.IsInteractive() && len(result.Candidates) > 0 { + selected, selErr := prompt.Select( + fmt.Sprintf("Select parent for %s:", branch), + result.Candidates, 0) + if selErr != nil { + continue + } + parent = result.Candidates[selected] + } else { + continue + } + } + + // Cycle check via config: walk GetParent from parent upward and + // ensure we never reach branch. This catches cycles that the tree + // model might miss (e.g., when nodes with broken parent links are + // omitted from tree.Build). + if wouldCycle(cfg, branch, parent) { + fmt.Printf("%s skipping %s: would create a cycle\n", + s.WarningIcon(), s.Branch(branch)) + adopted[branch] = true + continue + } + + // Commit adoption + if setErr := cfg.SetParent(branch, parent); setErr != nil { + fmt.Printf("%s failed to adopt %s: %v\n", + s.WarningIcon(), s.Branch(branch), setErr) + adopted[branch] = true + continue + } + + // Store fork point + forkPoint, fpErr := g.GetMergeBase(branch, parent) + if fpErr == nil { + _ = cfg.SetForkPoint(branch, forkPoint) //nolint:errcheck // best effort + } + + // Store PR number if detected via PR + if result.PRNumber > 0 { + _ = cfg.SetPR(branch, result.PRNumber) //nolint:errcheck // best effort + } + + confidenceLabel := "" + if result.Confidence == detect.High { + confidenceLabel = " (via PR)" + } + fmt.Printf("%s Auto-adopted %s with parent %s%s\n", + s.SuccessIcon(), s.Branch(branch), s.Branch(parent), confidenceLabel) + + tracked = append(tracked, branch) + adopted[branch] = true + progress = true + } + + if !progress { + break + } + } + + // Print guidance for any branches that couldn't be resolved + for _, branch := range candidates { + if !adopted[branch] { + fmt.Printf("%s could not determine parent for %s; run 'gh stack adopt --branch %s'\n", + s.WarningIcon(), s.Branch(branch), branch) + } + } + + return nil +} + +// sortCandidatesByTrunkDistance sorts branches by their merge-base distance +// from trunk (ascending). Branches closer to trunk are processed first, which +// ensures parents are adopted before their children in untracked chains. +// Branches whose distance can't be computed are sorted to the end. +func sortCandidatesByTrunkDistance(candidates []string, trunk string, g *git.Git) { + const maxDist = 1<<31 - 1 + dist := make(map[string]int, len(candidates)) + for _, b := range candidates { + mb, err := g.GetMergeBase(b, trunk) + if err != nil { + dist[b] = maxDist + continue + } + n, err := g.RevListCount(mb, b) + if err != nil { + dist[b] = maxDist + continue + } + dist[b] = n + } + slices.SortFunc(candidates, func(a, b string) int { + if d := cmp.Compare(dist[a], dist[b]); d != 0 { + return d + } + return cmp.Compare(a, b) + }) +} + +// wouldCycle returns true if setting branch's parent to parent would create a +// cycle in the config-based parent chain. It walks cfg.GetParent from parent +// upward; if it ever reaches branch, adopting would create a loop. +func wouldCycle(cfg *config.Config, branch, parent string) bool { + visited := make(map[string]bool) + cur := parent + for { + if cur == branch { + return true + } + if visited[cur] { + return false + } + visited[cur] = true + next, err := cfg.GetParent(cur) + if err != nil { + return false + } + cur = next + } +} diff --git a/cmd/init_test.go b/cmd/init_test.go index 6f2b2ff..a4bcf7c 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -23,6 +23,7 @@ func setupTestRepo(t *testing.T) string { run("init") run("config", "user.email", "test@test.com") run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") // Create main branch with initial commit f := filepath.Join(dir, "README.md") diff --git a/cmd/log.go b/cmd/log.go index d53c163..9b63b87 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -4,9 +4,11 @@ package cmd import ( "fmt" "os" + "sort" "strconv" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/style" @@ -24,13 +26,13 @@ var logCmd = &cobra.Command{ } var ( - logAllFlag bool logPorcelainFlag bool + logNoDetectFlag bool ) func init() { - logCmd.Flags().BoolVar(&logAllFlag, "all", false, "show all branches") logCmd.Flags().BoolVar(&logPorcelainFlag, "porcelain", false, "machine-readable output") + logCmd.Flags().BoolVar(&logNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(logCmd) } @@ -51,6 +53,14 @@ func runLog(cmd *cobra.Command, args []string) error { } g := git.New(cwd) + + // Auto-detect untracked branches (read-only — injects virtual nodes). + // Skip detection in porcelain mode so machine-readable output only contains + // tracked/configured nodes (porcelain has no column to mark detected ones). + if !logNoDetectFlag && !logPorcelainFlag { + injectDetectedNodes(root, cfg, g) + } + currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine for display // Try to get GitHub client for PR URLs (optional - may fail if not in a GitHub repo) @@ -74,6 +84,58 @@ func runLog(cmd *cobra.Command, args []string) error { return nil } +// injectDetectedNodes discovers untracked branches via merge-base analysis +// and injects them as virtual (Detected) nodes in the tree. This is strictly +// read-only — no git config is modified. +func injectDetectedNodes(root *tree.Node, cfg *config.Config, g *git.Git) { + trunk := root.Name + tracked, err := cfg.ListTrackedBranches() + if err != nil { + return // silent failure for read-only preview + } + + candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) + if err != nil { + return + } + + modified := make(map[*tree.Node]bool) + for _, branch := range candidates { + result, detectErr := detect.DetectParentLocal(branch, tracked, trunk, g) + if detectErr != nil || result.Confidence == detect.Ambiguous { + continue + } + + parentNode := tree.FindNode(root, result.Parent) + if parentNode == nil { + continue + } + + var cl tree.ConfidenceLevel + switch result.Confidence { + case detect.Medium: + cl = tree.ConfidenceMedium + case detect.High: + cl = tree.ConfidenceHigh + } + + node := &tree.Node{ + Name: branch, + Parent: parentNode, + Detected: true, + Confidence: cl, + } + parentNode.Children = append(parentNode.Children, node) + modified[parentNode] = true + } + + for parent := range modified { + sort.Slice(parent.Children, func(i, j int) bool { + return parent.Children[i].Name < parent.Children[j].Name + }) + } +} + // printPorcelain outputs stack information in table format. // In TTY mode, outputs nicely formatted columns. // In non-TTY mode (piped/scripted), outputs tab-separated values. diff --git a/cmd/log_internal_test.go b/cmd/log_internal_test.go new file mode 100644 index 0000000..2a79162 --- /dev/null +++ b/cmd/log_internal_test.go @@ -0,0 +1,123 @@ +// cmd/log_internal_test.go +// +// This file uses package cmd (not cmd_test) to unit-test the unexported +// injectDetectedNodes function directly. +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/tree" +) + +// setupInternalTestRepo creates a temp git repo with an initial commit. +// It mirrors setupTestRepo from init_test.go but lives in the internal +// test package so we can call unexported functions. +func setupInternalTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git %v failed: %v", args, err) + } + } + + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + f := filepath.Join(dir, "README.md") + if err := os.WriteFile(f, []byte("# Test"), 0644); err != nil { + t.Fatalf("WriteFile README.md: %v", err) + } + run("add", ".") + run("commit", "-m", "initial") + + return dir +} + +// addCommit creates a file and commits it in the given repo directory. +func addCommit(t *testing.T, dir, filename, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatalf("WriteFile %s: %v", filename, err) + } + if err := exec.Command("git", "-C", dir, "add", ".").Run(); err != nil { + t.Fatalf("git add: %v", err) + } + if err := exec.Command("git", "-C", dir, "commit", "-m", "add "+filename).Run(); err != nil { + t.Fatalf("git commit: %v", err) + } +} + +func TestLogDetectsUntrackedBranches(t *testing.T) { + dir := setupInternalTestRepo(t) + g := git.New(dir) + + cfg, err := config.Load(dir) + if err != nil { + t.Fatalf("Load config: %v", err) + } + + trunk, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + + err = cfg.SetTrunk(trunk) + if err != nil { + t.Fatalf("SetTrunk: %v", err) + } + + // Create tracked branch A off main + err = g.CreateAndCheckout("feature-a") + if err != nil { + t.Fatalf("CreateAndCheckout feature-a: %v", err) + } + addCommit(t, dir, "a.txt", "a") + + err = cfg.SetParent("feature-a", trunk) + if err != nil { + t.Fatalf("SetParent feature-a: %v", err) + } + + // Create untracked branch B off A + err = g.CreateAndCheckout("feature-b") + if err != nil { + t.Fatalf("CreateAndCheckout feature-b: %v", err) + } + addCommit(t, dir, "b.txt", "b") + + // Build tree WITHOUT detection -- B should not appear + root, err := tree.Build(cfg) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + if tree.FindNode(root, "feature-b") != nil { + t.Error("feature-b should NOT be in tracked tree") + } + + // Now run detection and inject into tree + injectDetectedNodes(root, cfg, g) + + // Now B should appear as detected child of A + nodeB := tree.FindNode(root, "feature-b") + if nodeB == nil { + t.Fatal("feature-b should appear after detection") + } + if !nodeB.Detected { + t.Error("feature-b should be marked as Detected") + } + if nodeB.Parent.Name != "feature-a" { + t.Errorf("expected parent 'feature-a', got %q", nodeB.Parent.Name) + } +} diff --git a/cmd/log_test.go b/cmd/log_test.go index b39ea73..c35d523 100644 --- a/cmd/log_test.go +++ b/cmd/log_test.go @@ -2,6 +2,7 @@ package cmd_test import ( + "strings" "testing" "github.com/boneskull/gh-stack/internal/config" @@ -108,3 +109,38 @@ func TestLogMultipleBranches(t *testing.T) { t.Errorf("expected 'feature-a-sub', got %q", featureA.Children[0].Name) } } + +func TestLogTreeWithDetectedNode(t *testing.T) { + dir := setupTestRepo(t) + cfg, _ := config.Load(dir) + cfg.SetTrunk("main") + cfg.SetParent("feature-a", "main") + + root, err := tree.Build(cfg) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + // Manually inject a detected node (simulating what log would do) + featureA := tree.FindNode(root, "feature-a") + if featureA == nil { + t.Fatal("feature-a not found") + } + + detected := &tree.Node{ + Name: "feature-b", + Parent: featureA, + Detected: true, + Confidence: tree.ConfidenceMedium, + } + featureA.Children = append(featureA.Children, detected) + + // Verify FormatTree includes the detected node with annotation + output := tree.FormatTree(root, tree.FormatOptions{}) + if !strings.Contains(output, "feature-b") { + t.Errorf("expected output to contain 'feature-b', got:\n%s", output) + } + if !strings.Contains(output, "detected") { + t.Errorf("expected output to contain 'detected' annotation, got:\n%s", output) + } +} diff --git a/cmd/submit.go b/cmd/submit.go index 843e7f1..340d43e 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -47,6 +47,9 @@ var ( submitFromFlag string ) +// ErrPRSkipped is returned when a user skips PR creation by pressing ESC. +var ErrPRSkipped = errors.New("PR creation skipped by user") + 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") @@ -299,6 +302,8 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr } else { prNum, adopted, err := createPRForBranch(g, ghClient, cfg, root, b.Name, parent, trunk, remoteBranches, s) switch { + case errors.Is(err, ErrPRSkipped): + fmt.Printf("Skipped PR for %s %s\n", s.Branch(b.Name), s.Muted("(user pressed ESC)")) case err != nil: fmt.Printf("%s failed to create PR for %s: %v\n", s.WarningIcon(), s.Branch(b.Name), err) case adopted: @@ -362,10 +367,13 @@ func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, } // Get title and body (prompt if interactive and --yes not set) - title, body, err := promptForPRDetails(branch, defaultTitle, defaultBody, s) + title, body, skipped, err := promptForPRDetails(branch, defaultTitle, defaultBody, s) if err != nil { return 0, false, fmt.Errorf("failed to get PR details: %w", err) } + if skipped { + return 0, false, ErrPRSkipped + } pr, err := ghClient.CreateSubmitPR(branch, base, title, body, draft) if err != nil { @@ -442,28 +450,33 @@ func toTitleCase(s string) string { } // promptForPRDetails prompts the user for PR title and body. -// If --yes flag is set or stdin is not a TTY, returns the defaults without prompting. -func promptForPRDetails(branch, defaultTitle, defaultBody string, s *style.Style) (title, body string, err error) { +// If --yes flag is set or not in an interactive terminal (stdin/stdout not TTYs), +// returns the defaults without prompting. +// Returns (title, body, skipped, error) where skipped is true if user pressed ESC. +func promptForPRDetails(branch, defaultTitle, defaultBody string, s *style.Style) (title, body string, skipped bool, err error) { // Skip prompts if --yes flag is set if submitYesFlag { - return defaultTitle, defaultBody, nil + return defaultTitle, defaultBody, false, nil } // Skip prompts if not interactive if !prompt.IsInteractive() { - return defaultTitle, defaultBody, nil + return defaultTitle, defaultBody, false, nil } - fmt.Printf("\n--- Creating PR for %s %s ---\n", s.Branch(branch), s.Muted("(use --yes to skip prompts)")) + fmt.Printf("\n--- Creating PR for %s %s ---\n", s.Branch(branch), s.Muted("(ESC to skip, --yes to skip prompts)")) - // Prompt for title - title, err = prompt.Input("PR title", defaultTitle) + // Prompt for title with skip support + title, skipped, err = prompt.InputWithSkip("PR title", "Press ESC to skip creating this PR", defaultTitle) if err != nil { - return "", "", err + return "", "", false, err + } + if skipped { + return "", "", true, nil } title = strings.TrimSpace(title) if title == "" { - return "", "", errors.New("PR title cannot be empty") + return "", "", false, errors.New("PR title cannot be empty") } // Show the generated body and ask if user wants to edit @@ -485,7 +498,7 @@ func promptForPRDetails(branch, defaultTitle, defaultBody string, s *style.Style editBody, err := prompt.Confirm("Edit description in editor?", false) if err != nil { - return "", "", err + return "", "", false, err } if editBody { @@ -499,7 +512,7 @@ func promptForPRDetails(branch, defaultTitle, defaultBody string, s *style.Style } fmt.Println() - return title, body, nil + return title, body, false, nil } // adoptExistingPR finds an existing PR for the branch and adopts it into the stack. diff --git a/cmd/sync.go b/cmd/sync.go index 85b2cc3..ab6aabd 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -28,12 +28,14 @@ var ( syncNoCascadeFlag bool syncDryRunFlag bool syncWorktreesFlag bool + syncNoDetectFlag bool ) func init() { syncCmd.Flags().BoolVar(&syncNoCascadeFlag, "no-restack", false, "skip restacking branches") syncCmd.Flags().BoolVar(&syncDryRunFlag, "dry-run", false, "show what would be done") syncCmd.Flags().BoolVar(&syncWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place") + syncCmd.Flags().BoolVar(&syncNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(syncCmd) } @@ -113,6 +115,13 @@ func runSync(cmd *cobra.Command, args []string) error { return err } + // Capture the starting branch to return to after sync completes + // Note: CurrentBranch() returns "HEAD" when in detached HEAD state + startingBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine + if startingBranch == "HEAD" { + startingBranch = "" // Treat detached HEAD as "no starting branch" + } + // Save undo snapshot of all tracked branches (unless dry-run) // This captures state before any modifications (fetch, delete, rebase) var stashRef string @@ -160,6 +169,13 @@ func runSync(cmd *cobra.Command, args []string) error { _ = g.Checkout(currentBranch) //nolint:errcheck // best effort } + // Auto-detect and adopt untracked branches + if !syncNoDetectFlag { + if adoptErr := autoDetectAndAdopt(cfg, g, gh, s); adoptErr != nil { + fmt.Printf("%s auto-detection: %v\n", s.WarningIcon(), adoptErr) + } + } + // Check for merged PRs branches, err := cfg.ListTrackedBranches() if err != nil { @@ -381,6 +397,22 @@ func runSync(cmd *cobra.Command, args []string) error { } } + // Return to the starting branch + if !syncDryRunFlag && startingBranch != "" { + // Only switch if we're not already there + currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine + if currentBranch != startingBranch { + // Check if the starting branch still exists (it may have been deleted during sync) + if g.BranchExists(startingBranch) { + if checkoutErr := g.Checkout(startingBranch); checkoutErr != nil { + fmt.Printf("%s could not return to starting branch %s: %v\n", s.WarningIcon(), s.Branch(startingBranch), checkoutErr) + } + } else { + fmt.Printf("%s starting ref %s is not a local branch or no longer exists, staying on %s\n", s.WarningIcon(), s.Branch(startingBranch), s.Branch(currentBranch)) + } + } + } + fmt.Println() fmt.Println(s.SuccessMessage("Sync complete!")) // Stash restoration handled by defer diff --git a/cmd/sync_test.go b/cmd/sync_test.go new file mode 100644 index 0000000..d26cb48 --- /dev/null +++ b/cmd/sync_test.go @@ -0,0 +1,237 @@ +// cmd/sync_test.go +package cmd_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/boneskull/gh-stack/internal/git" +) + +// TestSyncStartingBranchCapture tests that the starting branch capture logic +// correctly normalizes the branch name, particularly handling detached HEAD state. +func TestSyncStartingBranchCapture(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + // Normal branch case: should return the branch name + current, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + if current == "" || current == "HEAD" { + t.Errorf("expected a branch name, got %q", current) + } + + // Create and checkout a branch to verify we can get it + g.CreateAndCheckout("feature-test") + current, err = g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + if current != "feature-test" { + t.Errorf("expected 'feature-test', got %q", current) + } +} + +// TestSyncStartingBranchDetachedHEAD tests that detached HEAD state returns "HEAD" +// which the sync command should normalize to empty string. +func TestSyncStartingBranchDetachedHEAD(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + // Get current tip SHA + sha, err := g.GetTip("HEAD") + if err != nil { + t.Fatalf("GetTip failed: %v", err) + } + + // Detach HEAD by checking out a SHA + detachCmd := exec.Command("git", "-C", dir, "checkout", sha) + if detachErr := detachCmd.Run(); detachErr != nil { + t.Fatalf("git checkout %s failed: %v", sha, detachErr) + } + + // CurrentBranch returns "HEAD" when in detached HEAD state + current, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + if current != "HEAD" { + t.Errorf("expected 'HEAD' for detached HEAD state, got %q", current) + } + + // Sync command logic: normalize "HEAD" to empty string + // This is the logic we're testing (from cmd/sync.go lines 117-121) + startingBranch := current + if startingBranch == "HEAD" { + startingBranch = "" + } + if startingBranch != "" { + t.Errorf("expected empty string after normalization, got %q", startingBranch) + } +} + +// TestSyncReturnsToBranchAfterOperations tests that the return-to-branch logic +// correctly handles the case where we need to checkout back to the starting branch. +func TestSyncReturnsToBranchAfterOperations(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Create and checkout a starting branch + g.CreateAndCheckout("starting-branch") + + // Simulate sync operations by checking out another branch + g.Checkout(trunk) + + // Now simulate the return-to-branch logic + startingBranch := "starting-branch" + currentBranch, _ := g.CurrentBranch() + + if currentBranch != startingBranch { + // Branch exists, so we should be able to return to it + if g.BranchExists(startingBranch) { + if err := g.Checkout(startingBranch); err != nil { + t.Fatalf("could not return to starting branch: %v", err) + } + } + } + + // Verify we're back on starting branch + currentBranch, _ = g.CurrentBranch() + if currentBranch != "starting-branch" { + t.Errorf("expected to return to 'starting-branch', got %q", currentBranch) + } +} + +// TestSyncStartingBranchDeleted tests the scenario where the starting branch +// gets deleted during sync (e.g., it was merged and cleaned up). +func TestSyncStartingBranchDeleted(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Create and checkout a starting branch + g.CreateAndCheckout("to-be-deleted") + + // Record starting branch + startingBranch := "to-be-deleted" + + // Simulate sync: checkout trunk, then delete the starting branch + g.Checkout(trunk) + g.DeleteBranch(startingBranch) + + // Now simulate the return-to-branch logic + currentBranch, _ := g.CurrentBranch() + if currentBranch != startingBranch { + // Check if starting branch still exists + if g.BranchExists(startingBranch) { + t.Error("branch should have been deleted") + } + // Branch doesn't exist - this is the expected "warning" case + // Sync should stay on current branch and warn + } + + // Verify we stayed on trunk (didn't fail trying to checkout deleted branch) + currentBranch, _ = g.CurrentBranch() + if currentBranch != trunk { + t.Errorf("expected to stay on %q when starting branch deleted, got %q", trunk, currentBranch) + } +} + +// TestSyncCheckoutFailure tests the scenario where checkout fails +// (e.g., dirty worktree with conflicting changes prevents checkout). +func TestSyncCheckoutFailure(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + conflictFile := filepath.Join(dir, "conflict.txt") + + // Create and commit a tracked file on trunk. + if err := os.WriteFile(conflictFile, []byte("trunk base content"), 0644); err != nil { + t.Fatalf("failed to write initial conflict file on trunk: %v", err) + } + stageCmd := exec.Command("git", "-C", dir, "add", "conflict.txt") + if err := stageCmd.Run(); err != nil { + t.Fatalf("git add (trunk base) failed: %v", err) + } + commitCmd := exec.Command("git", "-C", dir, "commit", "-m", "add conflict file on trunk") + if err := commitCmd.Run(); err != nil { + t.Fatalf("git commit (trunk base) failed: %v", err) + } + + // Create starting-branch and commit different content for the same tracked file. + if err := g.CreateAndCheckout("starting-branch"); err != nil { + t.Fatalf("failed to create and checkout starting-branch: %v", err) + } + if err := os.WriteFile(conflictFile, []byte("starting-branch content"), 0644); err != nil { + t.Fatalf("failed to write conflict file on starting-branch: %v", err) + } + stageCmd2 := exec.Command("git", "-C", dir, "add", "conflict.txt") + if err := stageCmd2.Run(); err != nil { + t.Fatalf("git add (starting-branch) failed: %v", err) + } + commitCmd2 := exec.Command("git", "-C", dir, "commit", "-m", "change conflict file on starting-branch") + if err := commitCmd2.Run(); err != nil { + t.Fatalf("git commit (starting-branch) failed: %v", err) + } + + // Return to trunk and make an uncommitted change to the tracked file. + // Checking out starting-branch should now fail because the local change + // would be overwritten by the different version on that branch. + if err := g.Checkout(trunk); err != nil { + t.Fatalf("failed to checkout trunk: %v", err) + } + if err := os.WriteFile(conflictFile, []byte("trunk dirty content"), 0644); err != nil { + t.Fatalf("failed to dirty conflict file on trunk: %v", err) + } + + startingBranch := "starting-branch" + checkoutErr := g.Checkout(startingBranch) + if checkoutErr == nil { + t.Fatalf("expected checkout to %q to fail due to overwritten local changes", startingBranch) + } + + // After the failed checkout, we should still be on trunk. + currentBranch, _ := g.CurrentBranch() + if currentBranch != trunk { + t.Errorf("expected to stay on %q after checkout failure, got %q", trunk, currentBranch) + } +} + +// TestSyncEmptyStartingBranchSkipsReturn tests that when starting branch is empty +// (e.g., from detached HEAD), we don't attempt to return anywhere. +func TestSyncEmptyStartingBranchSkipsReturn(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Simulate having an empty starting branch (from detached HEAD normalization) + startingBranch := "" + + // Go to some other branch + g.CreateAndCheckout("other-branch") + + // The return logic should skip when startingBranch is empty + currentBranch, _ := g.CurrentBranch() + if startingBranch != "" && currentBranch != startingBranch { + if g.BranchExists(startingBranch) { + g.Checkout(startingBranch) + } + } + + // Verify we stayed on "other-branch" (didn't try to checkout empty string) + currentBranch, _ = g.CurrentBranch() + if currentBranch != "other-branch" { + t.Errorf("expected to stay on 'other-branch' with empty starting branch, got %q", currentBranch) + } + + _ = trunk // silence unused warning +} diff --git a/e2e/cascade_test.go b/e2e/cascade_test.go index 77904c7..43327f0 100644 --- a/e2e/cascade_test.go +++ b/e2e/cascade_test.go @@ -107,3 +107,36 @@ func TestCascadeContinue(t *testing.T) { env.AssertNoRebaseInProgress() } + +func TestCascadeReturnsToOriginalBranch(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create a 3-branch stack + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + + env.MustRun("create", "feature-b") + env.CreateCommit("feature b work") + + env.MustRun("create", "feature-c") + env.CreateCommit("feature c work") + + // Move main forward + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + // Start cascade from feature-a (not the deepest branch) + env.Git("checkout", "feature-a") + env.AssertBranch("feature-a") + + env.MustRun("cascade") + + // Verify we returned to feature-a, not feature-c (the last restacked branch) + env.AssertBranch("feature-a") + + // Verify all branches were restacked + env.AssertAncestor("main", "feature-a") + env.AssertAncestor("feature-a", "feature-b") + env.AssertAncestor("feature-b", "feature-c") +} diff --git a/e2e/sync_test.go b/e2e/sync_test.go new file mode 100644 index 0000000..42200c8 --- /dev/null +++ b/e2e/sync_test.go @@ -0,0 +1,30 @@ +// e2e/sync_test.go +package e2e_test + +import "testing" + +// Note: Full E2E testing of sync's return-to-original-branch behavior is limited +// because sync requires a real GitHub remote. The underlying return-to-branch logic +// is tested via TestCascadeReturnsToOriginalBranch in cascade_test.go, since sync +// delegates restacking to the same doCascadeWithState function. +// +// The fix for issue #58 (sync leaving user on wrong branch) is validated by: +// 1. TestCascadeReturnsToOriginalBranch - tests the shared restack infrastructure +// 2. cmd/sync_test.go unit tests - test sync-specific starting branch capture: +// - TestSyncStartingBranchCapture - normal branch detection +// - TestSyncStartingBranchDetachedHEAD - "HEAD" normalization for detached state +// - TestSyncReturnsToBranchAfterOperations - successful return to starting branch +// - TestSyncCheckoutFailure - checkout failure while returning to starting branch +// - TestSyncStartingBranchDeleted - handling when branch is deleted during sync +// - TestSyncEmptyStartingBranchSkipsReturn - empty starting branch skips return + +func TestSyncAcceptsDryRunFlag(t *testing.T) { + // Verify --dry-run flag is recognized by sync + env := NewTestEnv(t) + env.MustRun("init") + + result := env.Run("sync", "--help") + if !result.ContainsStdout("--dry-run") { + t.Errorf("expected sync --help to show --dry-run flag, got:\n%s", result.Stdout) + } +} diff --git a/go.mod b/go.mod index b532c8e..575c8fc 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,31 @@ module github.com/boneskull/gh-stack go 1.25.0 require ( + github.com/charmbracelet/huh v1.0.0 github.com/cli/go-gh/v2 v2.13.0 github.com/cli/safeexec v1.0.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/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // 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/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // 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/dustin/go-humanize v1.0.1 // 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.0.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -27,8 +36,11 @@ require ( 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-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // 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.0 // indirect @@ -37,7 +49,8 @@ require ( 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/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.30.0 // indirect golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index b695621..b289bab 100644 --- a/go.sum +++ b/go.sum @@ -4,18 +4,42 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ 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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +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.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 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/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 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/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 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/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +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/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 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= @@ -26,11 +50,16 @@ 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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= @@ -56,12 +85,20 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd 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-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.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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +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= @@ -92,25 +129,28 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t 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/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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= 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/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.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= diff --git a/internal/detect/detect.go b/internal/detect/detect.go new file mode 100644 index 0000000..a8d19cf --- /dev/null +++ b/internal/detect/detect.go @@ -0,0 +1,202 @@ +// Package detect provides automatic parent branch detection for untracked branches. +package detect + +import ( + "cmp" + "fmt" + "slices" + + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" +) + +// Confidence represents how certain the detection is. +type Confidence int + +const ( + // Ambiguous means multiple candidates tied or no signal was found. + Ambiguous Confidence = iota + // Medium means a unique merge-base winner was found. + Medium + // High means a PR base branch matched a tracked branch or trunk. + High +) + +// String returns a human-readable confidence label. +func (c Confidence) String() string { + switch c { + case High: + return "high" + case Medium: + return "medium" + case Ambiguous: + return "ambiguous" + default: + return "unknown" + } +} + +// Result holds the outcome of a parent detection attempt. +type Result struct { + Parent string + Confidence Confidence + PRNumber int // non-zero if detected via PR + Candidates []string // populated when Ambiguous, for prompting +} + +// candidate holds a scored potential parent. +type candidate struct { + name string + distance int +} + +// DetectParentLocal detects the parent branch using only local git data (no network). +// It computes merge-base distance between the untracked branch and each candidate +// (trunk + tracked branches), returning the closest unique match. +func DetectParentLocal(branch string, tracked []string, trunk string, g *git.Git) (*Result, error) { + return rankCandidates(branch, tracked, trunk, g) +} + +// DetectParent detects the parent branch using PR data first, falling back to +// local merge-base analysis. The GitHub client may be nil, in which case only +// local detection is used. +func DetectParent(branch string, tracked []string, trunk string, g *git.Git, gh *github.Client) (*Result, error) { + // Step 1: Try PR-based detection if GitHub client is available + if gh != nil { + pr, prErr := gh.FindPRByHead(branch) + if prErr == nil && pr != nil { + base := pr.Base.Ref + if isCandidate(base, tracked, trunk) { + return &Result{ + Parent: base, + Confidence: High, + PRNumber: pr.Number, + }, nil + } + // PR exists but base is not tracked -- fall through + } + // No PR or error -- fall through to merge-base + } + + // Step 2: Fall back to merge-base ranking + return rankCandidates(branch, tracked, trunk, g) +} + +// rankCandidates computes merge-base distance for each candidate and returns +// the closest unique match. +func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) (*Result, error) { + // Build candidate set: trunk + all tracked branches + seen := make(map[string]bool) + var candidates []candidate + var firstErr error + + // Resolve branch tip once so we can filter out descendants. + branchTip, tipErr := g.GetTip(branch) + if tipErr != nil { + return nil, fmt.Errorf("resolve tip of %s: %w", branch, tipErr) + } + + allCandidates := append([]string{trunk}, tracked...) + for _, name := range allCandidates { + if seen[name] || name == branch { + continue + } + seen[name] = true + + mergeBase, err := g.GetMergeBase(branch, name) + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("merge-base %s..%s: %w", branch, name, err) + } + continue + } + + // If merge-base equals branch tip AND name has commits ahead, + // then branch is an ancestor of name — name is a descendant, not + // a valid parent. Skip it to avoid inverted parent relationships. + // (When they're at the same commit, name is still a valid parent.) + if mergeBase == branchTip { + ahead, aErr := g.RevListCount(branchTip, name) + if aErr == nil && ahead > 0 { + continue + } + } + + distance, err := g.RevListCount(mergeBase, branch) + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("rev-list %s..%s: %w", mergeBase, branch, err) + } + continue + } + + candidates = append(candidates, candidate{name: name, distance: distance}) + } + + if len(candidates) == 0 { + if firstErr != nil { + return nil, fmt.Errorf("no candidates could be scored: %w", firstErr) + } + return &Result{Confidence: Ambiguous}, nil + } + + // Sort by distance ascending (closest fork = most likely parent), + // breaking ties by name for deterministic ordering. + slices.SortFunc(candidates, func(a, b candidate) int { + if d := cmp.Compare(a.distance, b.distance); d != 0 { + return d + } + return cmp.Compare(a.name, b.name) + }) + + best := candidates[0] + + // Check for tie with second-best + if len(candidates) > 1 && candidates[1].distance == best.distance { + // Ambiguous -- collect all tied candidates for prompting + var tied []string + for _, c := range candidates { + if c.distance == best.distance { + tied = append(tied, c.name) + } + } + slices.Sort(tied) + return &Result{ + Confidence: Ambiguous, + Candidates: tied, + }, nil + } + + return &Result{ + Parent: best.name, + Confidence: Medium, + }, nil +} + +// isCandidate returns true if the given branch name is trunk or in the tracked set. +func isCandidate(name string, tracked []string, trunk string) bool { + return name == trunk || slices.Contains(tracked, name) +} + +// FindUntrackedCandidates returns local branches that are not tracked and not trunk. +// These are candidates for auto-detection. +func FindUntrackedCandidates(g *git.Git, tracked []string, trunk string) ([]string, error) { + all, err := g.ListLocalBranches() + if err != nil { + return nil, fmt.Errorf("list local branches: %w", err) + } + + trackedSet := make(map[string]bool) + trackedSet[trunk] = true + for _, b := range tracked { + trackedSet[b] = true + } + + var candidates []string + for _, b := range all { + if !trackedSet[b] { + candidates = append(candidates, b) + } + } + return candidates, nil +} diff --git a/internal/detect/detect_test.go b/internal/detect/detect_test.go new file mode 100644 index 0000000..f7f985a --- /dev/null +++ b/internal/detect/detect_test.go @@ -0,0 +1,288 @@ +package detect_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/boneskull/gh-stack/internal/detect" + "github.com/boneskull/gh-stack/internal/git" +) + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git %v failed: %v", args, err) + } + } + + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + f := filepath.Join(dir, "README.md") + if err := os.WriteFile(f, []byte("# Test"), 0644); err != nil { + t.Fatalf("WriteFile README.md: %v", err) + } + run("add", ".") + run("commit", "-m", "initial") + + return dir +} + +func addCommit(t *testing.T, dir, filename, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatalf("WriteFile %s: %v", filename, err) + } + if err := exec.Command("git", "-C", dir, "add", ".").Run(); err != nil { + t.Fatalf("git add: %v", err) + } + if err := exec.Command("git", "-C", dir, "commit", "-m", "add "+filename).Run(); err != nil { + t.Fatalf("git commit: %v", err) + } +} + +// mustCreateAndCheckout is a test helper that creates+checks out a branch or fails. +func mustCreateAndCheckout(t *testing.T, g *git.Git, name string) { + t.Helper() + if err := g.CreateAndCheckout(name); err != nil { + t.Fatalf("CreateAndCheckout %s: %v", name, err) + } +} + +// mustCreateBranch is a test helper that creates a branch or fails. +func mustCreateBranch(t *testing.T, g *git.Git, name string) { + t.Helper() + if err := g.CreateBranch(name); err != nil { + t.Fatalf("CreateBranch %s: %v", name, err) + } +} + +// mustCheckout is a test helper that checks out a branch or fails. +func mustCheckout(t *testing.T, g *git.Git, name string) { + t.Helper() + if err := g.Checkout(name); err != nil { + t.Fatalf("Checkout %s: %v", name, err) + } +} + +// mustCurrentBranch returns the current branch name or fails. +func mustCurrentBranch(t *testing.T, g *git.Git) string { + t.Helper() + name, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + return name +} + +// Linear stack: main -> A -> B -> C (untracked) +// C should detect B as parent with Medium confidence +func TestDetectParentLocal_LinearStack(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateAndCheckout(t, g, "feature-a") + addCommit(t, dir, "a.txt", "a") + + mustCreateAndCheckout(t, g, "feature-b") + addCommit(t, dir, "b.txt", "b") + + mustCreateAndCheckout(t, g, "feature-c") + addCommit(t, dir, "c.txt", "c") + + tracked := []string{"feature-a", "feature-b"} + result, err := detect.DetectParentLocal("feature-c", tracked, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Parent != "feature-b" { + t.Errorf("expected parent 'feature-b', got %q", result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } +} + +// Two branches off main at the same commit: ambiguous +func TestDetectParentLocal_Ambiguous(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateBranch(t, g, "feature-a") + mustCreateBranch(t, g, "feature-b") + + mustCreateAndCheckout(t, g, "feature-c") + addCommit(t, dir, "c.txt", "c") + + tracked := []string{"feature-a", "feature-b"} + result, err := detect.DetectParentLocal("feature-c", tracked, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Confidence != detect.Ambiguous { + t.Errorf("expected Ambiguous confidence, got %v", result.Confidence) + } + if len(result.Candidates) < 2 { + t.Errorf("expected at least 2 tied candidates, got %v", result.Candidates) + } +} + +// Branch forked directly from trunk; no tracked branches are closer. +func TestDetectParentLocal_TrunkIsParent(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateAndCheckout(t, g, "feature-a") + addCommit(t, dir, "a.txt", "a") + + mustCheckout(t, g, trunk) + addCommit(t, dir, "main1.txt", "main1") + + mustCreateAndCheckout(t, g, "feature-x") + addCommit(t, dir, "x.txt", "x") + + tracked := []string{"feature-a"} + result, err := detect.DetectParentLocal("feature-x", tracked, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Parent != trunk { + t.Errorf("expected parent %q (trunk), got %q", trunk, result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } +} + +// No tracked branches, only trunk as candidate +func TestDetectParentLocal_NoTrackedBranches(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateAndCheckout(t, g, "feature-x") + addCommit(t, dir, "x.txt", "x") + + result, err := detect.DetectParentLocal("feature-x", nil, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Parent != trunk { + t.Errorf("expected parent %q (trunk), got %q", trunk, result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } +} + +// DetectParent with nil GitHub client should produce the same result as DetectParentLocal +func TestDetectParent_NilGitHub(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateAndCheckout(t, g, "feature-a") + addCommit(t, dir, "a.txt", "a") + + mustCreateAndCheckout(t, g, "feature-b") + addCommit(t, dir, "b.txt", "b") + + tracked := []string{"feature-a"} + result, err := detect.DetectParent("feature-b", tracked, trunk, g, nil) + if err != nil { + t.Fatalf("DetectParent failed: %v", err) + } + if result.Parent != "feature-a" { + t.Errorf("expected parent 'feature-a', got %q", result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } + if result.PRNumber != 0 { + t.Errorf("expected no PR number, got %d", result.PRNumber) + } +} + +// FindUntrackedCandidates should return branches not in tracked set and not trunk +func TestFindUntrackedCandidates(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateBranch(t, g, "tracked-a") + mustCreateBranch(t, g, "tracked-b") + mustCreateBranch(t, g, "untracked-x") + mustCreateBranch(t, g, "untracked-y") + + tracked := []string{"tracked-a", "tracked-b"} + candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) + if err != nil { + t.Fatalf("FindUntrackedCandidates failed: %v", err) + } + + candidateSet := make(map[string]bool) + for _, c := range candidates { + candidateSet[c] = true + } + + // Trunk and tracked branches should NOT be in candidates + for _, excluded := range []string{trunk, "tracked-a", "tracked-b"} { + if candidateSet[excluded] { + t.Errorf("expected %q to be excluded from candidates", excluded) + } + } + + // Untracked branches SHOULD be in candidates + for _, expected := range []string{"untracked-x", "untracked-y"} { + if !candidateSet[expected] { + t.Errorf("expected %q in candidates, got %v", expected, candidates) + } + } +} + +// FindUntrackedCandidates with empty tracked list +func TestFindUntrackedCandidates_EmptyTracked(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk := mustCurrentBranch(t, g) + + mustCreateBranch(t, g, "some-branch") + + candidates, err := detect.FindUntrackedCandidates(g, nil, trunk) + if err != nil { + t.Fatalf("FindUntrackedCandidates failed: %v", err) + } + + if len(candidates) != 1 || candidates[0] != "some-branch" { + t.Errorf("expected [some-branch], got %v", candidates) + } +} + +func TestConfidence_String(t *testing.T) { + tests := []struct { + c detect.Confidence + want string + }{ + {detect.Ambiguous, "ambiguous"}, + {detect.Medium, "medium"}, + {detect.High, "high"}, + {detect.Confidence(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.c.String(); got != tt.want { + t.Errorf("Confidence(%d).String() = %q, want %q", tt.c, got, tt.want) + } + } +} diff --git a/internal/git/git.go b/internal/git/git.go index c71232e..3340300 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -149,6 +149,20 @@ func (g *Git) GetMergeBase(a, b string) (string, error) { return g.run("merge-base", a, b) } +// RevListCount returns the number of commits reachable from 'to' but not from 'from'. +// Equivalent to `git rev-list --count from..to`. +func (g *Git) RevListCount(from, to string) (int, error) { + out, err := g.run("rev-list", "--count", from+".."+to) + if err != nil { + return 0, err + } + var count int + if _, scanErr := fmt.Sscanf(out, "%d", &count); scanErr != nil { + return 0, fmt.Errorf("parse rev-list count %q: %w", out, scanErr) + } + return count, nil +} + // GetTip returns the commit SHA at the tip of a branch. func (g *Git) GetTip(branch string) (string, error) { return g.run("rev-parse", branch) @@ -243,6 +257,22 @@ func (g *Git) ListRemoteBranches() (map[string]bool, error) { return branches, nil } +// ListLocalBranches returns the names of all local branches. +func (g *Git) ListLocalBranches() ([]string, error) { + out, err := g.run("for-each-ref", "--format=%(refname:short)", "refs/heads/") + if err != nil { + return nil, err + } + var branches []string + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + branches = append(branches, line) + } + } + return branches, nil +} + // RebaseOnto rebases a branch onto a new base, replaying only commits after oldBase. // Checks out the branch first, then runs: git rebase --onto // Useful when a parent branch was squash-merged and we need to replay only diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 5fc45a6..aa393a9 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -2,6 +2,7 @@ package git_test import ( + "fmt" "os" "os/exec" "path/filepath" @@ -709,6 +710,45 @@ func TestIsRebaseInProgressWorktree(t *testing.T) { } } +func TestListLocalBranches(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Create some branches + if err := g.CreateBranch("feature-a"); err != nil { + t.Fatalf("CreateBranch(feature-a): %v", err) + } + if err := g.CreateBranch("feature-b"); err != nil { + t.Fatalf("CreateBranch(feature-b): %v", err) + } + if err := g.CreateBranch("feature-c"); err != nil { + t.Fatalf("CreateBranch(feature-c): %v", err) + } + + branches, err := g.ListLocalBranches() + if err != nil { + t.Fatalf("ListLocalBranches failed: %v", err) + } + + // Should contain trunk + 3 feature branches + if len(branches) != 4 { + t.Errorf("expected 4 branches, got %d: %v", len(branches), branches) + } + + // Check all expected branches are present + branchSet := make(map[string]bool) + for _, b := range branches { + branchSet[b] = true + } + for _, expected := range []string{trunk, "feature-a", "feature-b", "feature-c"} { + if !branchSet[expected] { + t.Errorf("expected branch %q in list, got %v", expected, branches) + } + } +} + func TestRemoteBranchExists(t *testing.T) { // Create main repo dir := setupTestRepo(t) @@ -748,3 +788,54 @@ func TestRemoteBranchExists(t *testing.T) { t.Error("RemoteBranchExists(nonexistent) = true, want false") } } + +func TestRevListCount(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Create feature branch with 3 commits + if err := g.CreateAndCheckout("feature"); err != nil { + t.Fatalf("CreateAndCheckout(feature): %v", err) + } + for i := range 3 { + fname := filepath.Join(dir, fmt.Sprintf("file%d.txt", i)) + if err := os.WriteFile(fname, fmt.Appendf(nil, "content%d", i), 0644); err != nil { + t.Fatalf("WriteFile file%d.txt: %v", i, err) + } + if err := exec.Command("git", "-C", dir, "add", ".").Run(); err != nil { + t.Fatalf("git add (commit %d): %v", i, err) + } + if err := exec.Command("git", "-C", dir, "commit", "-m", fmt.Sprintf("commit %d", i)).Run(); err != nil { + t.Fatalf("git commit %d: %v", i, err) + } + } + + // Count commits from trunk to feature (should be 3) + count, err := g.RevListCount(trunk, "feature") + if err != nil { + t.Fatalf("RevListCount failed: %v", err) + } + if count != 3 { + t.Errorf("expected 3 commits, got %d", count) + } + + // Count from feature to trunk (should be 0 since trunk is behind) + count, err = g.RevListCount("feature", trunk) + if err != nil { + t.Fatalf("RevListCount failed: %v", err) + } + if count != 0 { + t.Errorf("expected 0 commits, got %d", count) + } + + // Count from same ref (should be 0) + count, err = g.RevListCount(trunk, trunk) + if err != nil { + t.Fatalf("RevListCount failed: %v", err) + } + if count != 0 { + t.Errorf("expected 0 commits, got %d", count) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index b06fa60..ae6fd69 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -8,6 +8,7 @@ import ( "os/exec" "strings" + "github.com/charmbracelet/huh" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/cli/go-gh/v2/pkg/term" "github.com/cli/safeexec" @@ -20,11 +21,12 @@ func init() { termState = term.FromEnv() } -// IsInteractive returns true if stdout is connected to a terminal. +// IsInteractive returns true if both stdin and stdout are connected to a terminal. +// This ensures prompts that require user input (like huh) won't hang when stdin is piped. // Respects GH_FORCE_TTY, NO_COLOR, CLICOLOR, and CLICOLOR_FORCE environment variables // for consistency with the gh CLI. func IsInteractive() bool { - return termState.IsTerminalOutput() + return termState.IsTerminalOutput() && term.IsTerminal(os.Stdin) } // newPrompter creates a prompter instance for interactive input. @@ -48,6 +50,38 @@ func Input(prompt, defaultValue string) (string, error) { return result, nil } +// InputWithSkip prompts the user for a single line of input with a default value, +// allowing the user to skip by pressing ESC. Returns (value, skipped, error). +// +// When the user presses ESC (or otherwise aborts via huh.ErrUserAborted), +// skipped is true and err is nil. Ctrl+C will typically terminate the process +// via SIGINT rather than returning here. +// +// If not in an interactive terminal, the default is returned without prompting. +func InputWithSkip(title, description, defaultValue string) (string, bool, error) { + if !IsInteractive() { + return defaultValue, false, nil + } + + value := defaultValue + + err := huh.NewInput(). + Title(title). + Description(description). + Value(&value). + Run() + + if errors.Is(err, huh.ErrUserAborted) { + // User pressed ESC to skip + return "", true, nil + } + if err != nil { + return "", false, fmt.Errorf("failed to read input: %w", err) + } + + return value, false, nil +} + // Confirm prompts the user for a yes/no confirmation. // Returns the defaultValue if not in an interactive terminal. func Confirm(prompt string, defaultValue bool) (bool, error) { @@ -65,7 +99,7 @@ func Confirm(prompt string, defaultValue bool) (bool, error) { // EditInEditor opens the given text in the user's preferred editor and returns // the edited content. Uses $VISUAL, then $EDITOR, then falls back to "vi". -// If stdin is not a TTY, returns the original text without editing. +// If not in an interactive terminal, returns the original text without editing. func EditInEditor(text string) (string, error) { if !IsInteractive() { return text, nil diff --git a/internal/tree/tree.go b/internal/tree/tree.go index 9bc5600..e82fcf8 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -10,12 +10,28 @@ import ( "github.com/boneskull/gh-stack/internal/style" ) +// ConfidenceLevel represents how certain a detection is. +type ConfidenceLevel int + +const ( + // ConfidenceNone is the zero value for tracked (non-detected) nodes. + ConfidenceNone ConfidenceLevel = iota + // ConfidenceAmbiguous means multiple candidates tied. + ConfidenceAmbiguous + // ConfidenceMedium means a unique merge-base winner was found. + ConfidenceMedium + // ConfidenceHigh means a PR base branch matched. + ConfidenceHigh +) + // Node represents a branch in the stack tree. type Node struct { - Name string - PR int // 0 if no PR - Parent *Node - Children []*Node + Name string + PR int // 0 if no PR + Parent *Node + Children []*Node + Detected bool // true for auto-detected, not yet persisted + Confidence ConfidenceLevel // detection confidence (only meaningful if Detected) } // Build constructs the stack tree from config. @@ -161,11 +177,28 @@ func formatNode(sb *strings.Builder, node *Node, prefix string, isLast bool, opt } } + // Detected branch annotation + detectedInfo := "" + if node.Detected { + switch node.Confidence { + case ConfidenceHigh: + detectedInfo = " (detected, via PR)" + case ConfidenceAmbiguous: + detectedInfo = " (detected, ambiguous)" + default: + detectedInfo = " (detected)" + } + if opts.Style != nil { + detectedInfo = opts.Style.Muted(detectedInfo) + } + } + sb.WriteString(prefix) sb.WriteString(connector) sb.WriteString(marker) sb.WriteString(branchDisplay) sb.WriteString(prInfo) + sb.WriteString(detectedInfo) sb.WriteString("\n") // Prepare prefix for children