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_cmd.go b/cmd/wt/checkout_cmd.go index d3e9367..31017d0 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -54,6 +54,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 { @@ -68,7 +69,7 @@ Target uses [scope:]branch format where scope can be a repo name or label: // Interactive mode if interactive { - result, err := runCheckoutInteractive(ctx, reg, hf) + result, err := runCheckoutInteractive(ctx, reg, hf, baseExplicit) if err != nil { return err } @@ -78,6 +79,9 @@ Target uses [scope:]branch format where scope can be a repo name or label: target = result.Target newBranch = result.NewBranch hf = result.HookFlags + if result.Base != "" { + base = result.Base + } } // Parse target @@ -306,7 +310,13 @@ 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 + } else { + l := log.FromContext(ctx) + l.Printf("Warning: %s not found, using local ref %s\n", remoteRef, baseRef) + } } if !git.RefExists(ctx, gitDir, baseRef) { @@ -551,14 +561,15 @@ func getEffectiveHooksForCompletion(ctx context.Context) map[string]config.Hook type checkoutInteractiveResult struct { Target string NewBranch bool + Base string HookFlags hookFlags Cancelled bool } // runCheckoutInteractive runs the checkout wizard and applies the selections to -// produce a resolved target, newBranch flag, and updated hook flags. -func runCheckoutInteractive(ctx context.Context, reg *registry.Registry, hf hookFlags) (checkoutInteractiveResult, error) { - wizOpts, err := runCheckoutWizard(ctx, reg, hf.HookNames, hf.NoHook) +// produce a resolved target, newBranch flag, base branch, and updated hook flags. +func runCheckoutInteractive(ctx context.Context, reg *registry.Registry, hf hookFlags, baseFromCLI bool) (checkoutInteractiveResult, error) { + wizOpts, err := runCheckoutWizard(ctx, reg, hf.HookNames, hf.NoHook, baseFromCLI) if err != nil { return checkoutInteractiveResult{}, err } @@ -581,12 +592,13 @@ func runCheckoutInteractive(ctx context.Context, reg *registry.Registry, hf hook return checkoutInteractiveResult{ Target: target, NewBranch: wizOpts.NewBranch, + Base: wizOpts.Base, HookFlags: hf, }, nil } // 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,17 +638,23 @@ 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) + // 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 + defaultBranch = result.DefaultBranch } // Build available hooks @@ -658,6 +676,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) diff --git a/cmd/wt/checkout_integration_test.go b/cmd/wt/checkout_integration_test.go index e7ca67c..f30b319 100644 --- a/cmd/wt/checkout_integration_test.go +++ b/cmd/wt/checkout_integration_test.go @@ -3672,3 +3672,131 @@ 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)") + } +} + +// 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 de31295..ed999c0 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) @@ -25,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. @@ -73,7 +75,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. @@ -104,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) @@ -136,36 +127,100 @@ 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) + // 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 baseOptions { + 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) } // 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 plain branch names (no worktree decoration) + baseStepUpdate, ok := wiz.GetStep("base").(*steps.FilterableListStep) + if !ok { + return + } + baseOpts := buildBaseBranchOptions(result.Branches) + baseStepUpdate.SetOptions(baseOpts) + if result.DefaultBranch != "" { + for i, opt := range baseOpts { + if opt.Value == result.DefaultBranch { + baseStepUpdate.SetCursor(i) + break + } + } + } }) } @@ -193,6 +248,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") @@ -202,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 01e8f01..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", @@ -165,7 +191,7 @@ func TestHookInfo_Structure(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 e3b3a7d..1bb5a65 100644 --- a/internal/ui/wizard/steps/filterable_list.go +++ b/internal/ui/wizard/steps/filterable_list.go @@ -698,6 +698,25 @@ func (s *FilterableListStep) GetCursor() int { return s.cursor } +// 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 { + 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 a3210a2..baaec2e 100644 --- a/internal/ui/wizard/steps/filterable_list_test.go +++ b/internal/ui/wizard/steps/filterable_list_test.go @@ -590,3 +590,56 @@ 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()) + } +} + +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()) + } +}