Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ RELEASE_CHECKLIST_GUIDE.md

website/
.idea/
tea_debug.log
36 changes: 28 additions & 8 deletions cmd/wt/checkout_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Silent local-ref fallback changes behavior for ALL checkouts, not just wizard

This applies to every wt checkout -b --base invocation. Previously, if origin/develop didn't exist, git would produce a clear error. Now it silently falls back to a potentially stale local ref with no logging.

Failure scenarios:

  • User forgot to fetch → branches from weeks-old local ref silently
  • Typo --base developp → silently uses local developp if it happens to exist

Suggestion: Add a warning log when falling back:

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)
}

Alternatively, consider restricting this fallback to only the wizard code path if it's not intended as a general behavior change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added warning log when falling back to local ref. Also added a companion integration test (TestCheckout_BaseBranch_PrefersRemoteOverLocal) that verifies remote is preferred when both exist.

} else {
l := log.FromContext(ctx)
l.Printf("Warning: %s not found, using local ref %s\n", remoteRef, baseRef)
}
}

if !git.RefExists(ctx, gitDir, baseRef) {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
128 changes: 128 additions & 0 deletions cmd/wt/checkout_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Missing test — verify remote ref still preferred when both exist

This test covers the fallback case (only local ref exists), but there's no test verifying the more important contract: when both origin/develop and local develop exist, the remote should still be preferred.

A future refactor could accidentally flip the preference, causing branches to silently create from stale local refs.

Suggestion: Add a companion test using setupTestRepoWithOrigin where both refs exist with different content, verifying the worktree gets the remote's content.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestCheckout_BaseBranch_PrefersRemoteOverLocal — creates a repo with origin, pushes develop, adds a remote-only commit, then verifies the worktree gets the remote content.

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 := &registry.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 := &registry.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)")
}
}
Loading
Loading