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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions cmd/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,26 @@ 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 <parent>",
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),
Comment thread
boneskull marked this conversation as resolved.
RunE: runAdopt,
}

Expand All @@ -42,9 +49,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
Expand Down Expand Up @@ -73,6 +78,52 @@ 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)
Comment thread
boneskull marked this conversation as resolved.
Expand Down Expand Up @@ -105,7 +156,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
}
119 changes: 119 additions & 0 deletions cmd/adopt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,34 @@
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/style"
"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)

Expand Down Expand Up @@ -148,3 +169,101 @@ 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, _ := config.Load(dir)
trunk, _ := g.CurrentBranch()
cfg.SetTrunk(trunk)

// Create tracked branch A
g.CreateAndCheckout("feature-a")
addCommit(t, dir, "a.txt", "a")
cfg.SetParent("feature-a", trunk)

// Create untracked branch B off A
g.CreateAndCheckout("feature-b")
Comment thread
boneskull marked this conversation as resolved.
Outdated
addCommit(t, dir, "b.txt", "b")

// feature-b should not be tracked yet
if _, err := cfg.GetParent("feature-b"); err == 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)
if err := cfg.SetParent("feature-b", result.Parent); 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)
}
}

// TestAdoptAutoDetect_PrintsConfidence verifies that the detection message
// includes the confidence level.
func TestAdoptAutoDetect_PrintsConfidence(t *testing.T) {
// Verify the style.New().Muted() call matches what adopt.go uses
s := style.New()
msg := s.Muted("(medium confidence)")
if msg == "" {
t.Error("expected non-empty muted confidence string")
}
}
Comment thread
boneskull marked this conversation as resolved.
Outdated
11 changes: 11 additions & 0 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Comment thread
boneskull marked this conversation as resolved.
}
}

// Build tree
root, err := tree.Build(cfg)
if err != nil {
Expand Down
Loading