From dcc5dc8920a38ff77e5f0249f2a391bd7c1d6346 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Fri, 17 Apr 2026 15:46:29 +0200 Subject: [PATCH 1/8] feat: add SetCursor method to FilterableListStep Co-Authored-By: Claude Opus 4.6 --- internal/ui/wizard/steps/filterable_list.go | 16 ++++++++ .../ui/wizard/steps/filterable_list_test.go | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/internal/ui/wizard/steps/filterable_list.go b/internal/ui/wizard/steps/filterable_list.go index e470caf..23be493 100644 --- a/internal/ui/wizard/steps/filterable_list.go +++ b/internal/ui/wizard/steps/filterable_list.go @@ -657,6 +657,22 @@ func (s *FilterableListStep) GetCursor() int { return s.cursor } +// SetCursor sets the cursor position, clamping to valid bounds. +func (s *FilterableListStep) SetCursor(idx int) *FilterableListStep { + maxIdx := len(s.filtered) - 1 + if maxIdx < 0 { + maxIdx = 0 + } + if idx < 0 { + idx = 0 + } + if idx > maxIdx { + idx = maxIdx + } + s.cursor = idx + return s +} + // GetSelectedValue returns the selected option's value, or nil if none. // If "Create" was selected, returns the filter string. func (s *FilterableListStep) GetSelectedValue() any { diff --git a/internal/ui/wizard/steps/filterable_list_test.go b/internal/ui/wizard/steps/filterable_list_test.go index fe5776e..35be99d 100644 --- a/internal/ui/wizard/steps/filterable_list_test.go +++ b/internal/ui/wizard/steps/filterable_list_test.go @@ -504,3 +504,40 @@ func TestFilterableListStep_RuneFilter(t *testing.T) { } }) } + +func TestFilterableListStep_SetCursor(t *testing.T) { + t.Parallel() + + options := []framework.Option{ + {Label: "alpha", Value: "alpha"}, + {Label: "beta", Value: "beta"}, + {Label: "gamma", Value: "gamma"}, + } + step := NewFilterableList("test", "Test", "Pick one", options) + + step.SetCursor(2) + + if step.GetCursor() != 2 { + t.Errorf("GetCursor() = %d, want 2", step.GetCursor()) + } +} + +func TestFilterableListStep_SetCursor_ClampsBounds(t *testing.T) { + t.Parallel() + + options := []framework.Option{ + {Label: "alpha", Value: "alpha"}, + {Label: "beta", Value: "beta"}, + } + step := NewFilterableList("test", "Test", "Pick one", options) + + step.SetCursor(10) + if step.GetCursor() != 1 { + t.Errorf("GetCursor() after out-of-bounds = %d, want 1", step.GetCursor()) + } + + step.SetCursor(-1) + if step.GetCursor() != 0 { + t.Errorf("GetCursor() after negative = %d, want 0", step.GetCursor()) + } +} From 185ffb12f1cffdddceab779044d57435c3495b52 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Fri, 17 Apr 2026 15:48:30 +0200 Subject: [PATCH 2/8] feat: add base branch step to checkout wizard Co-Authored-By: Claude Opus 4.6 --- internal/ui/wizard/flows/checkout.go | 48 ++++++++++++++++++++++- internal/ui/wizard/flows/checkout_test.go | 26 ++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/internal/ui/wizard/flows/checkout.go b/internal/ui/wizard/flows/checkout.go index de31295..d131287 100644 --- a/internal/ui/wizard/flows/checkout.go +++ b/internal/ui/wizard/flows/checkout.go @@ -11,6 +11,7 @@ import ( type CheckoutOptions struct { Branch string NewBranch bool + Base string // Base branch for new branch creation Cancelled bool SelectedRepos []string // Selected repo paths (when outside a repo) SelectedHooks []string // Hook names to run (empty if NoHook is true) @@ -73,7 +74,9 @@ type CheckoutWizardParams struct { PreSelectedRepos []int // Indices of pre-selected repos (e.g., current repo when inside one) FetchBranches BranchFetcher // Function to fetch branches for a repo AvailableHooks []HookInfo - HooksFromCLI bool // True if --hook or --no-hook was passed (skip hooks step) + HooksFromCLI bool // True if --hook or --no-hook was passed (skip hooks step) + DefaultBranch string // Default branch name for pre-selection in base step + BaseFromCLI bool // True if --base was explicitly passed (skip base step) } // CheckoutInteractive runs the interactive checkout wizard. @@ -136,7 +139,36 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { WithEmptyMessage("No matching branches") w.AddStep(branchStep) - // Step 3: Hooks (only when available and not set via CLI) + // Step 3: Base branch (only when creating new branch and not set via CLI) + baseStep := steps.NewFilterableList("base", "Base Branch", "Select a base branch to create from", branchOptions). + WithRuneFilter(framework.RuneFilterNoSpaces). + WithEmptyMessage("No matching branches") + + // Pre-select default branch + if params.DefaultBranch != "" { + for i, opt := range branchOptions { + if opt.Value == params.DefaultBranch { + baseStep.SetCursor(i) + break + } + } + } + + w.AddStep(baseStep) + + // Skip base step when selecting existing branch or --base passed on CLI + w.SkipWhen("base", func(wiz *framework.Wizard) bool { + if params.BaseFromCLI { + return true + } + branchStepResult, ok := wiz.GetStep("branch").(*steps.FilterableListStep) + if !ok { + return true + } + return !branchStepResult.IsCreateSelected() + }) + + // Step 4: Hooks (only when available and not set via CLI) hasHooks := len(params.AvailableHooks) > 0 && !params.HooksFromCLI if hasHooks { addHookStep(w, params.AvailableHooks) @@ -166,6 +198,13 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { } branchOpts := buildBranchOptions(result.Branches) branchStepUpdate.SetOptions(branchOpts) + + // Update base step with same branches + baseStepUpdate, ok := wiz.GetStep("base").(*steps.FilterableListStep) + if !ok { + return + } + baseStepUpdate.SetOptions(branchOpts) }) } @@ -193,6 +232,11 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { opts.NewBranch = branchStepResult.IsCreateSelected() } + // Base branch + if !params.BaseFromCLI { + opts.Base = result.GetString("base") + } + // Hooks if hasHooks { opts.SelectedHooks = result.GetStrings("hooks") diff --git a/internal/ui/wizard/flows/checkout_test.go b/internal/ui/wizard/flows/checkout_test.go index 01e8f01..8f3b7dd 100644 --- a/internal/ui/wizard/flows/checkout_test.go +++ b/internal/ui/wizard/flows/checkout_test.go @@ -158,6 +158,32 @@ func TestHookInfo_Structure(t *testing.T) { } } +func TestCheckoutOptions_IncludesBase(t *testing.T) { + opts := CheckoutOptions{ + Branch: "feature-x", + NewBranch: true, + Base: "main", + } + + if opts.Base != "main" { + t.Errorf("Base = %q, want %q", opts.Base, "main") + } +} + +func TestCheckoutWizardParams_IncludesDefaultBranch(t *testing.T) { + params := CheckoutWizardParams{ + DefaultBranch: "main", + BaseFromCLI: false, + } + + if params.DefaultBranch != "main" { + t.Errorf("DefaultBranch = %q, want %q", params.DefaultBranch, "main") + } + if params.BaseFromCLI { + t.Error("BaseFromCLI should be false") + } +} + // Note: Full interactive testing of CheckoutInteractive would require: // 1. Refactoring to separate wizard building from wizard.Run() // 2. Or using teatest with golden files for full TUI testing From c3efd1cc9dd371cef880fb5d3246dbb410cff854 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Fri, 17 Apr 2026 15:50:23 +0200 Subject: [PATCH 3/8] feat: wire base branch wizard step into checkout command Co-Authored-By: Claude Opus 4.6 --- cmd/wt/checkout_cmd.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/wt/checkout_cmd.go b/cmd/wt/checkout_cmd.go index 65b1239..1e8dc0a 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -56,6 +56,7 @@ Target uses [scope:]branch format where scope can be a repo name or label: cfg := config.FromContext(ctx) l := log.FromContext(ctx) fetchExplicit := cmd.Flags().Changed("fetch") + baseExplicit := cmd.Flags().Changed("base") var target string if len(args) > 0 { @@ -70,7 +71,7 @@ Target uses [scope:]branch format where scope can be a repo name or label: // Interactive mode if interactive { - wizOpts, err := runCheckoutWizard(ctx, reg, hf.HookNames, hf.NoHook) + wizOpts, err := runCheckoutWizard(ctx, reg, hf.HookNames, hf.NoHook, baseExplicit) if err != nil { return err } @@ -83,6 +84,9 @@ Target uses [scope:]branch format where scope can be a repo name or label: newBranch = wizOpts.NewBranch hf.HookNames = wizOpts.SelectedHooks hf.NoHook = wizOpts.NoHook + if wizOpts.Base != "" { + base = wizOpts.Base + } // Build scope:branch if repos selected if len(wizOpts.SelectedRepos) > 0 { @@ -573,7 +577,7 @@ func getEffectiveHooksForCompletion(ctx context.Context) map[string]config.Hook } // runCheckoutWizard runs the interactive checkout wizard -func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []string, cliNoHook bool) (flows.CheckoutOptions, error) { +func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []string, cliNoHook bool, baseFromCLI bool) (flows.CheckoutOptions, error) { l := log.FromContext(ctx) // Use global config for wizard — hooks from all repos are shown @@ -626,6 +630,14 @@ func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []s initialBranches = result.Branches } + // Detect default branch from first selected/available repo + var defaultBranch string + if len(preSelectedRepos) > 0 { + defaultBranch = git.GetDefaultBranch(ctx, repoPaths[preSelectedRepos[0]]) + } else if len(repoPaths) > 0 { + defaultBranch = git.GetDefaultBranch(ctx, repoPaths[0]) + } + // Build available hooks var availableHooks []flows.HookInfo for name, hook := range cfg.Hooks.Hooks { @@ -645,6 +657,8 @@ func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []s FetchBranches: fetchBranches, AvailableHooks: availableHooks, HooksFromCLI: len(cliHooks) > 0 || cliNoHook, + DefaultBranch: defaultBranch, + BaseFromCLI: baseFromCLI, } return flows.CheckoutInteractive(params) From 684d2415c4cf34149a7303cea889ebe701e5aaaa Mon Sep 17 00:00:00 2001 From: ppn26 Date: Fri, 17 Apr 2026 15:57:09 +0200 Subject: [PATCH 4/8] fix: consolidate OnComplete callbacks and update base cursor on repo change The previous implementation had two OnComplete("repos") callbacks which silently overwrote each other (map-based storage). This consolidates them into a single callback that handles branch reset, branch fetching, and base step updates. Also adds DefaultBranch to BranchFetchResult so the base step cursor is repositioned to the correct default branch when repos change. Co-Authored-By: Claude Opus 4.6 --- cmd/wt/checkout_cmd.go | 5 ++- internal/ui/wizard/flows/checkout.go | 52 +++++++++++++++++----------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/cmd/wt/checkout_cmd.go b/cmd/wt/checkout_cmd.go index 1e8dc0a..d521baf 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -617,7 +617,10 @@ func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []s InWorktree: wtBranches[b], }) } - return flows.BranchFetchResult{Branches: result} + return flows.BranchFetchResult{ + Branches: result, + DefaultBranch: git.GetDefaultBranch(ctx, repoPath), + } } // Build initial branches from first repo (or current repo) diff --git a/internal/ui/wizard/flows/checkout.go b/internal/ui/wizard/flows/checkout.go index d131287..3ede120 100644 --- a/internal/ui/wizard/flows/checkout.go +++ b/internal/ui/wizard/flows/checkout.go @@ -26,7 +26,8 @@ type BranchInfo struct { // BranchFetchResult contains branches with their worktree status. type BranchFetchResult struct { - Branches []BranchInfo + Branches []BranchInfo + DefaultBranch string // Default branch name (e.g. "main", "master") } // BranchFetcher is a function that fetches branches for a repo path. @@ -107,19 +108,6 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { } w.AddStep(repoStep) - - // Track previous repo selection to detect changes - var prevRepoSelection string - w.OnComplete("repos", func(wiz *framework.Wizard) { - currentSelection := wiz.GetStep("repos").Value().Label - if prevRepoSelection != "" && currentSelection != prevRepoSelection { - // Repo selection changed, reset branch step - if branchStep := wiz.GetStep("branch"); branchStep != nil { - branchStep.Reset() - } - } - prevRepoSelection = currentSelection - }) } // Step 2: Branch (combined mode + branch selection) @@ -175,36 +163,60 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { } // Callbacks - // When repos selection completes, fetch branches from first selected repo - if hasRepos && params.FetchBranches != nil { + // When repos selection completes, reset branch/base steps and fetch new branches + if hasRepos { + var prevRepoSelection string w.OnComplete("repos", func(wiz *framework.Wizard) { + // Reset branch step when repo selection changes + currentSelection := wiz.GetStep("repos").Value().Label + if prevRepoSelection != "" && currentSelection != prevRepoSelection { + if branchStep := wiz.GetStep("branch"); branchStep != nil { + branchStep.Reset() + } + if baseStep := wiz.GetStep("base"); baseStep != nil { + baseStep.Reset() + } + } + prevRepoSelection = currentSelection + + // Fetch branches from first selected repo + if params.FetchBranches == nil { + return + } repoStep, ok := wiz.GetStep("repos").(*steps.FilterableListStep) if !ok { - return // Skip if step not found or wrong type + return } indices := repoStep.GetSelectedIndices() if len(indices) == 0 { return } - // Fetch branches from first selected repo firstRepoPath := repoPaths[indices[0]] result := params.FetchBranches(firstRepoPath) // Update branch step with fetched branches branchStepUpdate, ok := wiz.GetStep("branch").(*steps.FilterableListStep) if !ok { - return // Skip if step not found or wrong type + return } branchOpts := buildBranchOptions(result.Branches) branchStepUpdate.SetOptions(branchOpts) - // Update base step with same branches + // Update base step with same branches and re-select default branch baseStepUpdate, ok := wiz.GetStep("base").(*steps.FilterableListStep) if !ok { return } baseStepUpdate.SetOptions(branchOpts) + if result.DefaultBranch != "" { + for i, opt := range branchOpts { + if opt.Value == result.DefaultBranch { + baseStepUpdate.SetCursor(i) + break + } + } + } }) } From 7219a581cc5ddf83bd9256371f929f0b50630fdc Mon Sep 17 00:00:00 2001 From: ppn26 Date: Mon, 20 Apr 2026 09:46:23 +0200 Subject: [PATCH 5/8] fix: fall back to local ref when remote base branch does not exist When creating a new branch from a base, the code unconditionally prepended origin/ to local refs. This fails for branches that only exist locally (no remote tracking branch). Now checks if the remote ref exists first and keeps the local ref as fallback. Co-Authored-By: Claude Opus 4.6 --- cmd/wt/checkout_cmd.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/wt/checkout_cmd.go b/cmd/wt/checkout_cmd.go index d521baf..3981131 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -324,7 +324,10 @@ func createWorktreeForBranch(ctx context.Context, gitDir, wtPath, branch string, // Use remote ref by default, unless already explicit or config says local _, _, isRemote := git.ParseRemoteRef(ctx, gitDir, baseRef) if !isRemote && baseRefMode != "local" { - baseRef = "origin/" + baseRef + remoteRef := "origin/" + baseRef + if git.RefExists(ctx, gitDir, remoteRef) { + baseRef = remoteRef + } } if !git.RefExists(ctx, gitDir, baseRef) { From 64401dc91a79a34a44ac861499b29885f71d56ba Mon Sep 17 00:00:00 2001 From: ppn26 Date: Mon, 20 Apr 2026 09:58:45 +0200 Subject: [PATCH 6/8] test: add coverage for base branch fallback and SetCursor edge cases - Add integration test for --base with local-only branch (no remote tracking ref) to cover the RefExists fallback in createWorktreeForBranch - Add SetCursor test for empty options list edge case - Remove tea_debug.log files and add to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + cmd/wt/checkout_integration_test.go | 61 +++++++++++++++++++ cmd/wt/tea_debug.log | 0 internal/hooks/tea_debug.log | 0 internal/ui/progress/tea_debug.log | 0 internal/ui/prompt/tea_debug.log | 0 internal/ui/static/tea_debug.log | 0 internal/ui/styles/tea_debug.log | 0 internal/ui/wizard/flows/tea_debug.log | 0 internal/ui/wizard/framework/tea_debug.log | 0 .../ui/wizard/steps/filterable_list_test.go | 16 +++++ internal/ui/wizard/steps/tea_debug.log | 0 12 files changed, 78 insertions(+) delete mode 100644 cmd/wt/tea_debug.log delete mode 100644 internal/hooks/tea_debug.log delete mode 100644 internal/ui/progress/tea_debug.log delete mode 100644 internal/ui/prompt/tea_debug.log delete mode 100644 internal/ui/static/tea_debug.log delete mode 100644 internal/ui/styles/tea_debug.log delete mode 100644 internal/ui/wizard/flows/tea_debug.log delete mode 100644 internal/ui/wizard/framework/tea_debug.log delete mode 100644 internal/ui/wizard/steps/tea_debug.log diff --git a/.gitignore b/.gitignore index d95cdeb..9e791d0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ RELEASE_CHECKLIST_GUIDE.md website/ .idea/ +tea_debug.log diff --git a/cmd/wt/checkout_integration_test.go b/cmd/wt/checkout_integration_test.go index e7ca67c..c3ef5d7 100644 --- a/cmd/wt/checkout_integration_test.go +++ b/cmd/wt/checkout_integration_test.go @@ -3672,3 +3672,64 @@ func TestCheckout_HooksRunAlphabetically(t *testing.T) { t.Errorf("expected alphabetical order (alpha, bravo, charlie), got:\n%s", lines) } } + +// TestCheckout_BaseBranch_LocalOnlyFallback tests that --base falls back to +// a local ref when the remote tracking branch does not exist. +// +// Scenario: User runs `wt checkout -b feature --base develop` where develop +// only exists locally (no origin/develop). BaseRef config is NOT set to "local". +// Expected: Branch is created from local develop ref (not origin/develop). +func TestCheckout_BaseBranch_LocalOnlyFallback(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpDir = resolvePath(t, tmpDir) + + repoPath := setupTestRepo(t, tmpDir, "test-repo") + + // Create a develop branch with a unique commit (local only, no remote tracking) + runGitCommand(repoPath, "branch", "develop") + runGitCommand(repoPath, "checkout", "develop") + addCommit(t, repoPath, "develop.txt", "Develop commit") + runGitCommand(repoPath, "checkout", "main") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + os.MkdirAll(filepath.Dir(regFile), 0755) + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "test-repo", Path: repoPath, WorktreeFormat: "../{repo}-{branch}"}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + // Note: BaseRef is NOT set to "local" — this is the default config + cfg := &config.Config{ + RegistryPath: regFile, + Checkout: config.CheckoutConfig{ + WorktreeFormat: "../{repo}-{branch}", + }, + } + ctx := testContextWithConfig(t, cfg, repoPath) + cmd := newCheckoutCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"-b", "feature", "--base", "develop"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("checkout command failed: %v (should fall back to local ref when origin/develop doesn't exist)", err) + } + + // Verify worktree was created + wtPath := filepath.Join(tmpDir, "test-repo-feature") + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + t.Fatalf("worktree should exist at %s", wtPath) + } + + // Verify the branch was created from develop (should have develop.txt) + developFile := filepath.Join(wtPath, "develop.txt") + if _, err := os.Stat(developFile); os.IsNotExist(err) { + t.Error("feature branch should have develop.txt (created from local develop)") + } +} diff --git a/cmd/wt/tea_debug.log b/cmd/wt/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/hooks/tea_debug.log b/internal/hooks/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/progress/tea_debug.log b/internal/ui/progress/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/prompt/tea_debug.log b/internal/ui/prompt/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/static/tea_debug.log b/internal/ui/static/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/styles/tea_debug.log b/internal/ui/styles/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/wizard/flows/tea_debug.log b/internal/ui/wizard/flows/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/wizard/framework/tea_debug.log b/internal/ui/wizard/framework/tea_debug.log deleted file mode 100644 index e69de29..0000000 diff --git a/internal/ui/wizard/steps/filterable_list_test.go b/internal/ui/wizard/steps/filterable_list_test.go index 0f491c6..baaec2e 100644 --- a/internal/ui/wizard/steps/filterable_list_test.go +++ b/internal/ui/wizard/steps/filterable_list_test.go @@ -627,3 +627,19 @@ func TestFilterableListStep_SetCursor_ClampsBounds(t *testing.T) { t.Errorf("GetCursor() after negative = %d, want 0", step.GetCursor()) } } + +func TestFilterableListStep_SetCursor_EmptyOptions(t *testing.T) { + t.Parallel() + + step := NewFilterableList("test", "Test", "Pick one", nil) + + step.SetCursor(0) + if step.GetCursor() != 0 { + t.Errorf("GetCursor() on empty list = %d, want 0", step.GetCursor()) + } + + step.SetCursor(5) + if step.GetCursor() != 0 { + t.Errorf("GetCursor() on empty list after out-of-bounds = %d, want 0", step.GetCursor()) + } +} diff --git a/internal/ui/wizard/steps/tea_debug.log b/internal/ui/wizard/steps/tea_debug.log deleted file mode 100644 index e69de29..0000000 From 570527a095d08b5c17a2b8fc79e886324a1e5907 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Mon, 20 Apr 2026 10:00:02 +0200 Subject: [PATCH 7/8] chore: remove superpowers specs and plans files Co-Authored-By: Claude Opus 4.6 --- ...-04-17-interactive-checkout-base-branch.md | 368 ------------------ ...interactive-checkout-base-branch-design.md | 61 --- 2 files changed, 429 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-17-interactive-checkout-base-branch.md delete mode 100644 docs/superpowers/specs/2026-04-17-interactive-checkout-base-branch-design.md diff --git a/docs/superpowers/plans/2026-04-17-interactive-checkout-base-branch.md b/docs/superpowers/plans/2026-04-17-interactive-checkout-base-branch.md deleted file mode 100644 index 2369f5a..0000000 --- a/docs/superpowers/plans/2026-04-17-interactive-checkout-base-branch.md +++ /dev/null @@ -1,368 +0,0 @@ -# Interactive Checkout Base Branch Selection — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a base branch selection step to `wt checkout -i` wizard when creating a new branch. - -**Architecture:** Add a single-select `FilterableListStep` for base branch between the branch and hooks steps, skipped via `SkipWhen` when the user selects an existing branch or `--base` was passed on CLI. Reuse existing branch options and add a `SetCursor` method for pre-selection. - -**Tech Stack:** Go, bubbletea wizard framework, FilterableListStep component - ---- - -### Task 1: Add `SetCursor` method to `FilterableListStep` - -**Files:** -- Modify: `internal/ui/wizard/steps/filterable_list.go` -- Test: `internal/ui/wizard/steps/filterable_list_test.go` - -- [ ] **Step 1: Write the failing test** - -In `internal/ui/wizard/steps/filterable_list_test.go`, add: - -```go -func TestFilterableListStep_SetCursor(t *testing.T) { - t.Parallel() - - options := []framework.Option{ - {Label: "alpha", Value: "alpha"}, - {Label: "beta", Value: "beta"}, - {Label: "gamma", Value: "gamma"}, - } - step := NewFilterableList("test", "Test", "Pick one", options) - - step.SetCursor(2) - - if step.GetCursor() != 2 { - t.Errorf("GetCursor() = %d, want 2", step.GetCursor()) - } -} - -func TestFilterableListStep_SetCursor_ClampsBounds(t *testing.T) { - t.Parallel() - - options := []framework.Option{ - {Label: "alpha", Value: "alpha"}, - {Label: "beta", Value: "beta"}, - } - step := NewFilterableList("test", "Test", "Pick one", options) - - step.SetCursor(10) - if step.GetCursor() != 1 { - t.Errorf("GetCursor() after out-of-bounds = %d, want 1", step.GetCursor()) - } - - step.SetCursor(-1) - if step.GetCursor() != 0 { - t.Errorf("GetCursor() after negative = %d, want 0", step.GetCursor()) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/ui/wizard/steps/ -run TestFilterableListStep_SetCursor -v` -Expected: FAIL — `SetCursor` method does not exist. - -- [ ] **Step 3: Implement `SetCursor`** - -In `internal/ui/wizard/steps/filterable_list.go`, add after the `GetCursor` method (line ~658): - -```go -// SetCursor sets the cursor position, clamping to valid bounds. -func (s *FilterableListStep) SetCursor(idx int) *FilterableListStep { - maxIdx := len(s.filtered) - 1 - if maxIdx < 0 { - maxIdx = 0 - } - if idx < 0 { - idx = 0 - } - if idx > maxIdx { - idx = maxIdx - } - s.cursor = idx - return s -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `go test ./internal/ui/wizard/steps/ -run TestFilterableListStep_SetCursor -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add internal/ui/wizard/steps/filterable_list.go internal/ui/wizard/steps/filterable_list_test.go -git commit -m "feat: add SetCursor method to FilterableListStep" -``` - ---- - -### Task 2: Add base branch step to checkout wizard - -**Files:** -- Modify: `internal/ui/wizard/flows/checkout.go` -- Test: `internal/ui/wizard/flows/checkout_test.go` - -- [ ] **Step 1: Write the failing test** - -In `internal/ui/wizard/flows/checkout_test.go`, add: - -```go -func TestCheckoutOptions_IncludesBase(t *testing.T) { - opts := CheckoutOptions{ - Branch: "feature-x", - NewBranch: true, - Base: "main", - } - - if opts.Base != "main" { - t.Errorf("Base = %q, want %q", opts.Base, "main") - } -} - -func TestCheckoutWizardParams_IncludesDefaultBranch(t *testing.T) { - params := CheckoutWizardParams{ - DefaultBranch: "main", - BaseFromCLI: false, - } - - if params.DefaultBranch != "main" { - t.Errorf("DefaultBranch = %q, want %q", params.DefaultBranch, "main") - } - if params.BaseFromCLI { - t.Error("BaseFromCLI should be false") - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/ui/wizard/flows/ -run "TestCheckoutOptions_IncludesBase|TestCheckoutWizardParams_IncludesDefaultBranch" -v` -Expected: FAIL — `Base` field does not exist on `CheckoutOptions`, `DefaultBranch`/`BaseFromCLI` do not exist on `CheckoutWizardParams`. - -- [ ] **Step 3: Add fields to structs** - -In `internal/ui/wizard/flows/checkout.go`, add `Base` field to `CheckoutOptions`: - -```go -type CheckoutOptions struct { - Branch string - NewBranch bool - Base string // Base branch for new branch creation - Cancelled bool - SelectedRepos []string - SelectedHooks []string - NoHook bool -} -``` - -Add `DefaultBranch` and `BaseFromCLI` fields to `CheckoutWizardParams`: - -```go -type CheckoutWizardParams struct { - Branches []BranchInfo - AvailableRepos []string - RepoNames []string - PreSelectedRepos []int - FetchBranches BranchFetcher - AvailableHooks []HookInfo - HooksFromCLI bool - DefaultBranch string // Default branch name for pre-selection in base step - BaseFromCLI bool // True if --base was explicitly passed (skip base step) -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `go test ./internal/ui/wizard/flows/ -run "TestCheckoutOptions_IncludesBase|TestCheckoutWizardParams_IncludesDefaultBranch" -v` -Expected: PASS - -- [ ] **Step 5: Add the base step to `CheckoutInteractive`** - -In `internal/ui/wizard/flows/checkout.go`, in the `CheckoutInteractive` function, add after the branch step (after line 137 `w.AddStep(branchStep)`) and before the hooks step: - -```go - // Step 3: Base branch (only when creating new branch and not set via CLI) - baseStep := steps.NewFilterableList("base", "Base Branch", "Select a base branch to create from", branchOptions). - WithRuneFilter(framework.RuneFilterNoSpaces). - WithEmptyMessage("No matching branches") - - // Pre-select default branch - if params.DefaultBranch != "" { - for i, opt := range branchOptions { - if opt.Value == params.DefaultBranch { - baseStep.SetCursor(i) - break - } - } - } - - w.AddStep(baseStep) - - // Skip base step when selecting existing branch or --base passed on CLI - w.SkipWhen("base", func(wiz *framework.Wizard) bool { - if params.BaseFromCLI { - return true - } - branchStepResult, ok := wiz.GetStep("branch").(*steps.FilterableListStep) - if !ok { - return true - } - return !branchStepResult.IsCreateSelected() - }) -``` - -- [ ] **Step 6: Update `OnComplete("repos", ...)` callback to also update base step** - -In the existing `OnComplete("repos", ...)` callback (around line 148-170), add after the `branchStepUpdate.SetOptions(branchOpts)` line: - -```go - // Update base step with same branches - baseStepUpdate, ok := wiz.GetStep("base").(*steps.FilterableListStep) - if !ok { - return - } - baseStepUpdate.SetOptions(branchOpts) -``` - -- [ ] **Step 7: Extract base value from wizard result** - -In the result extraction section of `CheckoutInteractive` (around line 183-200), add after the branch extraction and before the hooks extraction: - -```go - // Base branch - if !params.BaseFromCLI { - opts.Base = result.GetString("base") - } -``` - -- [ ] **Step 8: Run all checkout flow tests** - -Run: `go test ./internal/ui/wizard/flows/ -v` -Expected: PASS - -- [ ] **Step 9: Commit** - -```bash -git add internal/ui/wizard/flows/checkout.go internal/ui/wizard/flows/checkout_test.go -git commit -m "feat: add base branch step to checkout wizard" -``` - ---- - -### Task 3: Wire base branch into `runCheckoutWizard` and apply result - -**Files:** -- Modify: `cmd/wt/checkout_cmd.go` - -- [ ] **Step 1: Pass `--base` CLI state and default branch to wizard params** - -In `cmd/wt/checkout_cmd.go`, in the `RunE` function, capture the `--base` explicit flag (add after `fetchExplicit` on line 58): - -```go - baseExplicit := cmd.Flags().Changed("base") -``` - -Then update `runCheckoutWizard` call (line 73) to pass the new flag: - -```go - wizOpts, err := runCheckoutWizard(ctx, reg, hf.HookNames, hf.NoHook, baseExplicit) -``` - -- [ ] **Step 2: Update `runCheckoutWizard` signature and detect default branch** - -Update the `runCheckoutWizard` function signature (line 576): - -```go -func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []string, cliNoHook bool, baseFromCLI bool) (flows.CheckoutOptions, error) { -``` - -Then, after the `initialBranches` block (after line 627), add: - -```go - // Detect default branch from first selected/available repo - var defaultBranch string - if len(preSelectedRepos) > 0 { - defaultBranch = git.GetDefaultBranch(ctx, repoPaths[preSelectedRepos[0]]) - } else if len(repoPaths) > 0 { - defaultBranch = git.GetDefaultBranch(ctx, repoPaths[0]) - } -``` - -Then update the `params` struct (around line 640) to include the new fields: - -```go - params := flows.CheckoutWizardParams{ - Branches: initialBranches, - AvailableRepos: repoPaths, - RepoNames: repoNames, - PreSelectedRepos: preSelectedRepos, - FetchBranches: fetchBranches, - AvailableHooks: availableHooks, - HooksFromCLI: len(cliHooks) > 0 || cliNoHook, - DefaultBranch: defaultBranch, - BaseFromCLI: baseFromCLI, - } -``` - -- [ ] **Step 3: Apply wizard base result to the `base` variable** - -In the interactive mode block (after line 85 `hf.NoHook = wizOpts.NoHook`), add: - -```go - if wizOpts.Base != "" { - base = wizOpts.Base - } -``` - -- [ ] **Step 4: Build and verify** - -Run: `go build ./cmd/wt` -Expected: Compiles successfully. - -- [ ] **Step 5: Run all unit tests** - -Run: `go test ./...` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add cmd/wt/checkout_cmd.go -git commit -m "feat: wire base branch wizard step into checkout command" -``` - ---- - -### Task 4: Manual smoke test - -- [ ] **Step 1: Build the binary** - -Run: `just build` - -- [ ] **Step 2: Test interactive new branch with base selection** - -Run: `./wt checkout -i` -- Select a repo (if multiple) -- Type a new branch name that doesn't exist → select "Create" -- Verify the base branch step appears with branches listed -- Verify the default branch is pre-selected -- Select a base branch and continue -- Verify worktree is created with correct base - -- [ ] **Step 3: Test interactive existing branch (base step skipped)** - -Run: `./wt checkout -i` -- Select an existing branch from the list -- Verify the base branch step is NOT shown -- Verify checkout works normally - -- [ ] **Step 4: Test `--base` flag skips wizard step** - -Run: `./wt checkout -i --base develop` -- Create a new branch in the wizard -- Verify the base branch step is NOT shown (skipped because `--base` was explicit) -- Verify the branch is created from `develop` diff --git a/docs/superpowers/specs/2026-04-17-interactive-checkout-base-branch-design.md b/docs/superpowers/specs/2026-04-17-interactive-checkout-base-branch-design.md deleted file mode 100644 index 84b30b4..0000000 --- a/docs/superpowers/specs/2026-04-17-interactive-checkout-base-branch-design.md +++ /dev/null @@ -1,61 +0,0 @@ -# Interactive Checkout Base Branch Selection - -## Overview - -Extend `wt checkout -i` to include a base branch selection step when creating a new branch. Currently, `--base` is only available as a CLI flag; this adds it to the interactive wizard flow. - -## Wizard Flow (Updated) - -1. **Repos** — select repositories (unchanged) -2. **Branch** — select or create a branch (unchanged) -3. **Base** — select base branch to create from (**new step**) -4. **Hooks** — select hooks to run (unchanged) - -## Base Branch Step - -- **Component:** `FilterableListStep` (single-select) -- **Options:** Same branch list already fetched for the branch step (reuse `buildBranchOptions`) -- **Pre-selection:** Default branch (detected via `git.GetDefaultBranch`) pre-selected -- **Skip condition:** `SkipWhen("base", ...)` — skip when `branchStep.IsCreateSelected()` returns `false` (i.e., user selected an existing branch, not creating a new one), OR when `--base` was explicitly passed on the CLI -- **Filter:** `RuneFilterNoSpaces` (consistent with branch step) - -## Data Flow Changes - -### `CheckoutOptions` (flows/checkout.go) - -Add `Base string` field to hold the selected base branch. - -### `CheckoutWizardParams` (flows/checkout.go) - -Add `DefaultBranch string` field. The caller (`runCheckoutWizard`) resolves this from the first selected repo using `git.GetDefaultBranch`. Also add `BaseFromCLI bool` to indicate `--base` was explicitly set on CLI (skip the wizard step). - -### `runCheckoutWizard` (checkout_cmd.go) - -- Detect the default branch from the first selected repo (or current repo) -- Pass it via `CheckoutWizardParams.DefaultBranch` - -### `CheckoutInteractive` (flows/checkout.go) - -- Add a `FilterableListStep` for "base" after the branch step -- Use `SkipWhen("base", ...)` to skip when the branch step didn't create a new branch -- Pre-select the default branch in the options list -- On repos change (`OnComplete("repos", ...)`): update base step options alongside the existing branch step update, and update pre-selection to the new repo's default branch - -### `checkout_cmd.go` interactive block - -Apply `wizOpts.Base` to the `base` variable: - -```go -base = wizOpts.Base -``` - -### Summary - -The base branch appears automatically in the wizard summary since it's a standard wizard step with a value. When skipped (existing branch), it won't appear. - -## What Doesn't Change - -- Non-interactive `--base` flag behavior -- `createWorktreeForBranch` logic (already handles base ref resolution) -- Hook step behavior -- Branch step behavior From 44e9ec9b63e3d95578542509b76a0378dafb2902 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Mon, 20 Apr 2026 10:45:13 +0200 Subject: [PATCH 8/8] fix: address PR review feedback for base branch selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add warning log when falling back from missing remote ref to local ref - Add integration test verifying remote ref is preferred over local - Remove zero-value struct tests that tested Go assignment - Use plain branch names (no worktree decoration) in base step - Clarify SetCursor doc comment re: create option offset - Fix stale comment: "Fetch step" → "Base step" - Eliminate duplicate GetDefaultBranch call using fetchBranches result Co-Authored-By: Claude Opus 4.6 --- cmd/wt/checkout_cmd.go | 16 +++-- cmd/wt/checkout_integration_test.go | 67 +++++++++++++++++++++ internal/ui/wizard/flows/checkout.go | 27 +++++++-- internal/ui/wizard/flows/checkout_test.go | 54 ++++++++--------- internal/ui/wizard/steps/filterable_list.go | 3 + 5 files changed, 126 insertions(+), 41 deletions(-) diff --git a/cmd/wt/checkout_cmd.go b/cmd/wt/checkout_cmd.go index 5b58257..31017d0 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -313,6 +313,9 @@ func createWorktreeForBranch(ctx context.Context, gitDir, wtPath, branch string, remoteRef := "origin/" + baseRef if git.RefExists(ctx, gitDir, remoteRef) { baseRef = remoteRef + } else { + l := log.FromContext(ctx) + l.Printf("Warning: %s not found, using local ref %s\n", remoteRef, baseRef) } } @@ -641,22 +644,17 @@ func runCheckoutWizard(ctx context.Context, reg *registry.Registry, cliHooks []s } } - // Build initial branches from first repo (or current repo) + // Build initial branches and default branch from first repo (or current repo) var initialBranches []flows.BranchInfo + var defaultBranch string if len(preSelectedRepos) > 0 { result := fetchBranches(repoPaths[preSelectedRepos[0]]) initialBranches = result.Branches + defaultBranch = result.DefaultBranch } else if len(repoPaths) > 0 { result := fetchBranches(repoPaths[0]) initialBranches = result.Branches - } - - // Detect default branch from first selected/available repo - var defaultBranch string - if len(preSelectedRepos) > 0 { - defaultBranch = git.GetDefaultBranch(ctx, repoPaths[preSelectedRepos[0]]) - } else if len(repoPaths) > 0 { - defaultBranch = git.GetDefaultBranch(ctx, repoPaths[0]) + defaultBranch = result.DefaultBranch } // Build available hooks diff --git a/cmd/wt/checkout_integration_test.go b/cmd/wt/checkout_integration_test.go index c3ef5d7..f30b319 100644 --- a/cmd/wt/checkout_integration_test.go +++ b/cmd/wt/checkout_integration_test.go @@ -3733,3 +3733,70 @@ func TestCheckout_BaseBranch_LocalOnlyFallback(t *testing.T) { t.Error("feature branch should have develop.txt (created from local develop)") } } + +// TestCheckout_BaseBranch_PrefersRemoteOverLocal tests that --base uses the +// remote tracking branch when both local and remote refs exist. +// +// Scenario: User runs `wt checkout -b feature --base develop` where both +// local develop and origin/develop exist but have diverged (remote has extra +// content). Expected: Branch is created from origin/develop (remote content). +func TestCheckout_BaseBranch_PrefersRemoteOverLocal(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpDir = resolvePath(t, tmpDir) + + repoPath, _ := setupTestRepoWithOrigin(t, tmpDir, "test-repo") + + // Create develop branch with a commit and push to origin + runGitCommand(repoPath, "checkout", "-b", "develop") + addCommit(t, repoPath, "develop-base.txt", "Base develop commit") + runGitCommand(repoPath, "push", "-u", "origin", "develop") + + // Add a remote-only commit: push, then reset local branch back + addCommit(t, repoPath, "remote-only.txt", "Remote-only commit") + runGitCommand(repoPath, "push", "origin", "develop") + runGitCommand(repoPath, "reset", "--hard", "HEAD~1") + + // Back to main for checkout + runGitCommand(repoPath, "checkout", "main") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + os.MkdirAll(filepath.Dir(regFile), 0755) + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "test-repo", Path: repoPath, WorktreeFormat: "../{repo}-{branch}"}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{ + RegistryPath: regFile, + Checkout: config.CheckoutConfig{ + WorktreeFormat: "../{repo}-{branch}", + }, + } + ctx := testContextWithConfig(t, cfg, repoPath) + cmd := newCheckoutCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"-b", "feature", "--base", "develop"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("checkout command failed: %v", err) + } + + // Verify worktree was created + wtPath := filepath.Join(tmpDir, "test-repo-feature") + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + t.Fatalf("worktree should exist at %s", wtPath) + } + + // Verify the branch was created from origin/develop (should have remote-only.txt) + remoteFile := filepath.Join(wtPath, "remote-only.txt") + if _, err := os.Stat(remoteFile); os.IsNotExist(err) { + t.Error("feature branch should have remote-only.txt (created from origin/develop, not local develop)") + } +} diff --git a/internal/ui/wizard/flows/checkout.go b/internal/ui/wizard/flows/checkout.go index 3ede120..ed999c0 100644 --- a/internal/ui/wizard/flows/checkout.go +++ b/internal/ui/wizard/flows/checkout.go @@ -128,13 +128,16 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { w.AddStep(branchStep) // Step 3: Base branch (only when creating new branch and not set via CLI) - baseStep := steps.NewFilterableList("base", "Base Branch", "Select a base branch to create from", branchOptions). + // Use plain branch names without worktree decoration — the user is selecting + // a branch to fork from, not opening a worktree. + baseOptions := buildBaseBranchOptions(params.Branches) + baseStep := steps.NewFilterableList("base", "Base Branch", "Select a base branch to create from", baseOptions). WithRuneFilter(framework.RuneFilterNoSpaces). WithEmptyMessage("No matching branches") // Pre-select default branch if params.DefaultBranch != "" { - for i, opt := range branchOptions { + for i, opt := range baseOptions { if opt.Value == params.DefaultBranch { baseStep.SetCursor(i) break @@ -203,14 +206,15 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { branchOpts := buildBranchOptions(result.Branches) branchStepUpdate.SetOptions(branchOpts) - // Update base step with same branches and re-select default branch + // Update base step with plain branch names (no worktree decoration) baseStepUpdate, ok := wiz.GetStep("base").(*steps.FilterableListStep) if !ok { return } - baseStepUpdate.SetOptions(branchOpts) + baseOpts := buildBaseBranchOptions(result.Branches) + baseStepUpdate.SetOptions(baseOpts) if result.DefaultBranch != "" { - for i, opt := range branchOpts { + for i, opt := range baseOpts { if opt.Value == result.DefaultBranch { baseStepUpdate.SetCursor(i) break @@ -258,6 +262,19 @@ func CheckoutInteractive(params CheckoutWizardParams) (CheckoutOptions, error) { return opts, nil } +// buildBaseBranchOptions creates Option slice from branches using plain names +// (no worktree decoration), suitable for the base branch selection step. +func buildBaseBranchOptions(branches []BranchInfo) []framework.Option { + var opts []framework.Option + for _, branch := range branches { + opts = append(opts, framework.Option{ + Label: branch.Name, + Value: branch.Name, + }) + } + return opts +} + // buildBranchOptions creates Option slice from branches, appending " (worktree)" to branches that already have a worktree. func buildBranchOptions(branches []BranchInfo) []framework.Option { var opts []framework.Option diff --git a/internal/ui/wizard/flows/checkout_test.go b/internal/ui/wizard/flows/checkout_test.go index 8f3b7dd..ace0b12 100644 --- a/internal/ui/wizard/flows/checkout_test.go +++ b/internal/ui/wizard/flows/checkout_test.go @@ -96,6 +96,32 @@ func TestBuildBranchOptions_WorktreeLabelDiffersFromValue(t *testing.T) { } } +func TestBuildBaseBranchOptions_NoWorktreeDecoration(t *testing.T) { + t.Parallel() + + branches := []BranchInfo{ + {Name: "main", InWorktree: true}, + {Name: "feature-a", InWorktree: false}, + {Name: "develop", InWorktree: true}, + } + + opts := buildBaseBranchOptions(branches) + + if len(opts) != 3 { + t.Fatalf("expected 3 options, got %d", len(opts)) + } + + // All labels should be plain branch names, regardless of InWorktree + for i, opt := range opts { + if opt.Label != branches[i].Name { + t.Errorf("opts[%d].Label = %q, want %q (no worktree decoration)", i, opt.Label, branches[i].Name) + } + if opt.Value != branches[i].Name { + t.Errorf("opts[%d].Value = %v, want %q", i, opt.Value, branches[i].Name) + } + } +} + func TestCheckoutOptions_Structure(t *testing.T) { opts := CheckoutOptions{ Branch: "feature-x", @@ -158,32 +184,6 @@ func TestHookInfo_Structure(t *testing.T) { } } -func TestCheckoutOptions_IncludesBase(t *testing.T) { - opts := CheckoutOptions{ - Branch: "feature-x", - NewBranch: true, - Base: "main", - } - - if opts.Base != "main" { - t.Errorf("Base = %q, want %q", opts.Base, "main") - } -} - -func TestCheckoutWizardParams_IncludesDefaultBranch(t *testing.T) { - params := CheckoutWizardParams{ - DefaultBranch: "main", - BaseFromCLI: false, - } - - if params.DefaultBranch != "main" { - t.Errorf("DefaultBranch = %q, want %q", params.DefaultBranch, "main") - } - if params.BaseFromCLI { - t.Error("BaseFromCLI should be false") - } -} - // Note: Full interactive testing of CheckoutInteractive would require: // 1. Refactoring to separate wizard building from wizard.Run() // 2. Or using teatest with golden files for full TUI testing @@ -191,7 +191,7 @@ func TestCheckoutWizardParams_IncludesDefaultBranch(t *testing.T) { // The wizard has complex behavior: // - Repo step triggers branch fetch callback // - Branch step supports create-from-filter -// - Fetch step is conditionally skipped for existing branches +// - Base step is conditionally skipped for existing branches // - Hooks step pre-selects default hooks // // To test these, we would need to: diff --git a/internal/ui/wizard/steps/filterable_list.go b/internal/ui/wizard/steps/filterable_list.go index d79e5df..1bb5a65 100644 --- a/internal/ui/wizard/steps/filterable_list.go +++ b/internal/ui/wizard/steps/filterable_list.go @@ -699,6 +699,9 @@ func (s *FilterableListStep) GetCursor() int { } // SetCursor sets the cursor position, clamping to valid bounds. +// The idx refers to the position in the filtered options list (0-based), +// not the visual cursor position (which may include a "create" option +// at position 0 when WithCreateFromFilter is enabled). func (s *FilterableListStep) SetCursor(idx int) *FilterableListStep { maxIdx := len(s.filtered) - 1 if maxIdx < 0 {