Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ bt go user/repo # Jump with more specific path
#### Work with worktrees

```bash
bt add -b feature/auth # Create feature branch
bt add -b feature/auth # Create feature branch (auto-fetches remotes)
bt add -b feature/auth --no-fetch # Skip auto-fetch
bt cd feature/auth # Jump to worktree
bt ls # List all worktrees
bt rm feature/auth # Remove when done
Expand Down Expand Up @@ -153,7 +154,8 @@ cd ~/projects/my-project
#### Work with worktrees

```bash
bt add -b feature/auth # Create feature branch
bt add -b feature/auth # Create feature branch (auto-fetches remotes)
bt add -b feature/auth --no-fetch # Skip auto-fetch
bt cd feature/auth # Jump to worktree
bt ls # List all worktrees
bt rm feature/auth # Remove when done
Expand Down Expand Up @@ -324,7 +326,7 @@ cp examples/rules/working-directory-on-git-worktree-with-baretree.md .cursor/rul

| Command | Description |
|---------|-------------|
| `bt add <branch>` | Add worktree (`-b` for new branch) |
| `bt add <branch>` | Add worktree (`-b` for new branch, auto-fetches remotes, `--no-fetch` to skip) |
| `bt list` / `bt ls` | List worktrees |
| `bt remove` / `bt rm` | Remove worktree (`--with-branch` to delete branch) |
| `bt cd <name>` | Switch to worktree (`@` for default, `-` for previous) |
Expand Down
79 changes: 71 additions & 8 deletions cmd/bt/add.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package main

import (
"bufio"
"errors"
"fmt"
"os"
"strings"

"github.com/amaya382/baretree/internal/repository"
"github.com/amaya382/baretree/internal/worktree"
Expand All @@ -15,14 +17,17 @@ var (
addBaseBranch string
addDetach bool
addForce bool
addFetch bool
addNoFetch bool
)

var addCmd = &cobra.Command{
Use: "add <branch-name>",
Short: "Create a worktree for a branch (creates branch with -b)",
Long: `Create a new worktree for a branch.

When remotes are configured, fetches from all remotes before adding the worktree
(use --no-fetch to skip).

Supports multiple modes:
1. Create new branch: bt add -b feature/new
2. Existing local branch: bt add existing-branch
Expand All @@ -42,7 +47,7 @@ Examples:
bt add existing-local-branch # Uses existing local branch
bt add feature/remote # Auto-detects and tracks origin/feature/remote
bt add upstream/feature/test # Tracks upstream/feature/test
bt add --fetch feature/new # Fetch before adding`,
bt add --no-fetch feature/new # Skip auto-fetch from remotes`,
Args: cobra.ExactArgs(1),
RunE: runAdd,
}
Expand All @@ -52,7 +57,7 @@ func init() {
addCmd.Flags().StringVar(&addBaseBranch, "base", "", "Base branch for new branch (default: HEAD)")
addCmd.Flags().BoolVar(&addDetach, "detach", false, "Create detached HEAD worktree")
addCmd.Flags().BoolVar(&addForce, "force", false, "Force creation even if worktree exists")
addCmd.Flags().BoolVar(&addFetch, "fetch", false, "Fetch from remote before adding worktree")
addCmd.Flags().BoolVar(&addNoFetch, "no-fetch", false, "Skip auto-fetch from remotes")
}

func runAdd(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -84,8 +89,8 @@ func runAdd(cmd *cobra.Command, args []string) error {
// Create worktree manager
wtMgr := worktree.NewManager(repoRoot, bareDir, mgr.Config)

// Fetch if requested
if addFetch {
// Auto-fetch unless --no-fetch is specified or no remotes configured
if !addNoFetch && wtMgr.Executor.HasRemotes() {
fmt.Println("Fetching from remotes...")
if err := wtMgr.Fetch(""); err != nil {
return fmt.Errorf("failed to fetch: %w", err)
Expand All @@ -95,6 +100,7 @@ func runAdd(cmd *cobra.Command, args []string) error {
// Resolve base branch if specified
var resolvedBaseBranch string
var baseDisplayInfo string
var baseIsLocal bool
if addBaseBranch != "" {
baseInfo, err := wtMgr.ResolveBranch(addBaseBranch)
if err != nil {
Expand All @@ -103,10 +109,11 @@ func runAdd(cmd *cobra.Command, args []string) error {

if baseInfo.IsLocal {
resolvedBaseBranch = baseInfo.Name
baseDisplayInfo = baseInfo.Name
baseDisplayInfo = baseInfo.Name + " (local)"
baseIsLocal = true
} else if baseInfo.IsRemote {
resolvedBaseBranch = baseInfo.RemoteRef
baseDisplayInfo = baseInfo.RemoteRef
baseDisplayInfo = baseInfo.RemoteRef + " (remote)"
} else {
return fmt.Errorf("base branch '%s' not found locally or on any remote", addBaseBranch)
}
Expand All @@ -119,6 +126,7 @@ func runAdd(cmd *cobra.Command, args []string) error {
}

var branchName string
var resolvedBranchIsLocal bool

if addNewBranch {
// Creating a new branch - use spec as-is
Expand All @@ -133,6 +141,7 @@ func runAdd(cmd *cobra.Command, args []string) error {
if branchInfo.IsLocal {
// Local branch exists
branchName = branchInfo.Name
resolvedBranchIsLocal = true
} else if branchInfo.IsRemote {
// Remote branch found - create tracking branch
branchName = branchInfo.Name
Expand All @@ -144,12 +153,57 @@ func runAdd(cmd *cobra.Command, args []string) error {
}
}

// Upstream behind detection
if !addForce {
var branchToCheck string

if addNewBranch {
if addBaseBranch != "" && baseIsLocal {
// --base was specified and resolved to a local branch
branchToCheck = resolvedBaseBranch
} else if addBaseBranch == "" {
// No --base: check the default branch
branchToCheck = mgr.Config.Repository.DefaultBranch
if branchToCheck == "" {
branchToCheck = "main"
}
}
} else if resolvedBranchIsLocal {
// Not -b mode: local branch resolved, check it
branchToCheck = branchName
}

if branchToCheck != "" {
behindCount, _ := wtMgr.Executor.GetUpstreamBehindCount(branchToCheck)

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

The error returned by GetUpstreamBehindCount is silently ignored. While the function is designed to return 0 on error, it would be better to log or acknowledge the error. If an error occurs (e.g., upstream ref is invalid or git command fails), the user might assume no upstream is configured when in fact there was an error checking it. Consider at least logging the error for debugging purposes.

Copilot uses AI. Check for mistakes.
if behindCount > 0 {
if isTerminal() {
fmt.Printf("Warning: '%s' is %d commit(s) behind its upstream.\n", branchToCheck, behindCount)
fmt.Printf("Continue anyway? [y/N]: ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

Error from ReadString is silently ignored. If reading from stdin fails (e.g., stdin is closed), the response will be empty, which is treated as "no" and aborts the operation. While this is a safe default, it would be better to explicitly handle the error to provide a clearer error message to the user, distinguishing between "user said no" and "failed to read input".

Copilot uses AI. Check for mistakes.
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
return fmt.Errorf("aborted: '%s' is behind upstream", branchToCheck)
}
} else {
// Non-TTY: warn but proceed
fmt.Printf("Warning: '%s' is %d commit(s) behind its upstream (non-interactive, proceeding).\n", branchToCheck, behindCount)
}
}
}
}

// Display base information
if addNewBranch {
if baseDisplayInfo != "" {
fmt.Printf("Based on '%s'\n", baseDisplayInfo)
} else {
fmt.Println("Based on HEAD")
headBranch := wtMgr.Executor.ResolveHEAD()
if headBranch != "" {
fmt.Printf("Based on HEAD (%s)\n", headBranch)
} else {
fmt.Println("Based on HEAD")
}
}
}

Expand Down Expand Up @@ -195,3 +249,12 @@ func runAdd(cmd *cobra.Command, args []string) error {

return nil
}

// isTerminal checks if stdin is connected to a terminal
func isTerminal() bool {
stat, err := os.Stdin.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) != 0
}
4 changes: 3 additions & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ Remote branch operation tests.
|-----------|--------------|
| `TestAddRemoteBranch` | Auto-detection and addition of remote branch |
| `TestAddRemoteBranchExplicit` | Explicit addition with `origin/branch` format |
| `TestAddWithFetch` | Fetching latest branches with `--fetch` option |
| `TestAddAutoFetch` | Auto-fetch from remotes by default when adding worktree |
| `TestAddNoFetch` | `--no-fetch` option skips auto-fetch |
| `TestAddUpstreamBehindWarning` | Warning when local branch is behind its upstream |
| `TestAddBranchNotFound` | Error message when adding non-existent branch |
| `TestAddLocalBranchPriority` | Local branch priority over remote |
| `TestAddNewBranchWithRemoteBase` | `--base` with remote-only branch resolves correctly (DWIM bug fix) |
Expand Down
2 changes: 1 addition & 1 deletion e2e/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func TestCompletion_Flags(t *testing.T) {
assertOutputContains(t, stdout, "--base")
assertOutputContains(t, stdout, "--detach")
assertOutputContains(t, stdout, "--force")
assertOutputContains(t, stdout, "--fetch")
assertOutputContains(t, stdout, "--no-fetch")
})

t.Run("remove command completes flags", func(t *testing.T) {
Expand Down
124 changes: 117 additions & 7 deletions e2e/journey_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ func TestAddRemoteBranchExplicit(t *testing.T) {
})
}

// TestAddWithFetch tests the --fetch option
func TestAddWithFetch(t *testing.T) {
// TestAddAutoFetch tests that auto-fetch is the default when remotes are configured
func TestAddAutoFetch(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}
Expand Down Expand Up @@ -181,8 +181,8 @@ func TestAddWithFetch(t *testing.T) {
cmd.Dir = workPath
_ = cmd.Run()

t.Run("add with --fetch gets new remote branches", func(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "--fetch", "feature/new-after-clone")
t.Run("auto-fetch gets new remote branches by default", func(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "feature/new-after-clone")

assertOutputContains(t, stdout, "Fetching from remotes")
assertOutputContains(t, stdout, "Tracking remote branch")
Expand All @@ -191,6 +191,116 @@ func TestAddWithFetch(t *testing.T) {
})
}

// TestAddNoFetch tests the --no-fetch option skips auto-fetch
func TestAddNoFetch(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

tempDir := createTempDir(t, "no-fetch")
originPath := setupRemoteRepo(t, tempDir)

runBtSuccess(t, tempDir, "repo", "clone", originPath, "test-repo")
projectDir := filepath.Join(tempDir, "test-repo")

// Configure fetch refspec
bareDir := filepath.Join(projectDir, ".git")
cmd := exec.Command("git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
cmd.Dir = bareDir
_ = cmd.Run()

// Create a new branch on origin after clone (not yet fetched)
workPath := filepath.Join(tempDir, "work")
cmd = exec.Command("git", "checkout", "-b", "feature/unfetched")
cmd.Dir = workPath
_ = cmd.Run()
newFilePath := filepath.Join(workPath, "unfetched.txt")
_ = os.WriteFile(newFilePath, []byte("unfetched"), 0644)
cmd = exec.Command("git", "add", ".")
cmd.Dir = workPath
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "unfetched feature")
cmd.Dir = workPath
_ = cmd.Run()
cmd = exec.Command("git", "push", "origin", "feature/unfetched")
cmd.Dir = workPath
_ = cmd.Run()

t.Run("no-fetch skips auto-fetch so branch is not found", func(t *testing.T) {
_, stderr := runBtExpectError(t, projectDir, "add", "--no-fetch", "feature/unfetched")

assertOutputNotContains(t, stderr, "Fetching from remotes")
Comment on lines +230 to +232

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

The assertion checks that "Fetching from remotes" is NOT in stderr, but the "Fetching from remotes..." message is printed to stdout (not stderr) in cmd/bt/add.go line 94 using fmt.Println. Since runBtExpectError returns both stdout and stderr separately, this assertion should check stdout instead of stderr, or check the combined output. Otherwise, this test may pass for the wrong reason.

Suggested change
_, stderr := runBtExpectError(t, projectDir, "add", "--no-fetch", "feature/unfetched")
assertOutputNotContains(t, stderr, "Fetching from remotes")
stdout, stderr := runBtExpectError(t, projectDir, "add", "--no-fetch", "feature/unfetched")
assertOutputNotContains(t, stdout, "Fetching from remotes")

Copilot uses AI. Check for mistakes.
assertOutputContains(t, stderr, "not found")
})
}

// TestAddUpstreamBehindWarning tests the upstream behind warning in non-TTY mode
func TestAddUpstreamBehindWarning(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

tempDir := createTempDir(t, "upstream-behind")
originPath := setupRemoteRepo(t, tempDir)

runBtSuccess(t, tempDir, "repo", "clone", originPath, "test-repo")
projectDir := filepath.Join(tempDir, "test-repo")

bareDir := filepath.Join(projectDir, ".git")

// Configure fetch refspec and fetch
cmd := exec.Command("git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
cmd.Dir = bareDir
_ = cmd.Run()
cmd = exec.Command("git", "fetch", "origin")
cmd.Dir = bareDir
_ = cmd.Run()

// Set upstream for main branch
cmd = exec.Command("git", "config", "branch.main.remote", "origin")
cmd.Dir = bareDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "branch.main.merge", "refs/heads/main")
cmd.Dir = bareDir
_ = cmd.Run()

// Push a new commit to origin after clone
workPath := filepath.Join(tempDir, "work")
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = workPath
_ = cmd.Run()
newFilePath := filepath.Join(workPath, "extra.txt")
_ = os.WriteFile(newFilePath, []byte("extra"), 0644)
cmd = exec.Command("git", "add", ".")
cmd.Dir = workPath
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "extra commit")
cmd.Dir = workPath
_ = cmd.Run()
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workPath
_ = cmd.Run()

t.Run("aborts when default branch is behind upstream without force", func(t *testing.T) {
// auto-fetch will update remote refs, making local main behind origin/main
stdout, stderr := runBtExpectError(t, projectDir, "add", "-b", "feat/behind-test")

// Should show warning about being behind
combined := stdout + stderr
assertOutputContains(t, combined, "Warning: 'main' is")
assertOutputContains(t, combined, "behind its upstream")
Comment on lines +284 to +291

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

This test expects runBtExpectError (an error/abort), but according to the implementation in cmd/bt/add.go lines 188-191, when running in non-TTY mode (which automated tests are), the code warns but proceeds without aborting. This test should use runBtSuccess and verify that the warning is present in stdout, not expect an error. The current test will fail when executed.

Suggested change
t.Run("aborts when default branch is behind upstream without force", func(t *testing.T) {
// auto-fetch will update remote refs, making local main behind origin/main
stdout, stderr := runBtExpectError(t, projectDir, "add", "-b", "feat/behind-test")
// Should show warning about being behind
combined := stdout + stderr
assertOutputContains(t, combined, "Warning: 'main' is")
assertOutputContains(t, combined, "behind its upstream")
t.Run("warns when default branch is behind upstream without force", func(t *testing.T) {
// auto-fetch will update remote refs, making local main behind origin/main
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/behind-test")
// Should show warning about being behind
assertOutputContains(t, stdout, "Warning: 'main' is")
assertOutputContains(t, stdout, "behind its upstream")

Copilot uses AI. Check for mistakes.
})

t.Run("force skips behind check", func(t *testing.T) {
// --force should skip the behind check and create the worktree
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/behind-force", "--force")

// Should not show warning
assertOutputNotContains(t, stdout, "Warning:")
assertOutputContains(t, stdout, "Worktree created")
})
}

// TestAddBranchNotFound tests error when branch doesn't exist
func TestAddBranchNotFound(t *testing.T) {
if testing.Short() {
Expand Down Expand Up @@ -276,7 +386,7 @@ func TestAddNewBranchWithRemoteBase(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/new", "--base", "feature/remote")

// Should show the resolved remote ref as base
assertOutputContains(t, stdout, "Based on 'origin/feature/remote'")
assertOutputContains(t, stdout, "Based on 'origin/feature/remote (remote)'")
assertOutputContains(t, stdout, "Worktree created")

// Verify worktree was created with correct branch name
Expand Down Expand Up @@ -307,7 +417,7 @@ func TestAddNewBranchWithLocalBase(t *testing.T) {
t.Run("new branch based on local main", func(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/from-main", "--base", "main")

assertOutputContains(t, stdout, "Based on 'main'")
assertOutputContains(t, stdout, "Based on 'main (local)'")
assertOutputContains(t, stdout, "Worktree created")
assertFileExists(t, filepath.Join(projectDir, "feat", "from-main"))
})
Expand Down Expand Up @@ -345,7 +455,7 @@ func TestAddNewBranchShowsBaseInfo(t *testing.T) {
t.Run("new branch without --base shows HEAD", func(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/no-base")

assertOutputContains(t, stdout, "Based on HEAD")
assertOutputContains(t, stdout, "Based on HEAD (main)")
assertOutputContains(t, stdout, "Worktree created")
})
}
Loading