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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ bt go user/repo # Jump with more specific path
```bash
bt add -b feature/auth # Create feature branch (auto-fetches remotes)
bt add -b feature/auth --no-fetch # Skip auto-fetch
bt add -b feature/auth --behind=pull # Pull base branch if behind upstream, then create
bt cd feature/auth # Jump to worktree
bt ls # List all worktrees
bt rm feature/auth # Remove when done
Expand Down Expand Up @@ -156,6 +157,7 @@ cd ~/projects/my-project
```bash
bt add -b feature/auth # Create feature branch (auto-fetches remotes)
bt add -b feature/auth --no-fetch # Skip auto-fetch
bt add -b feature/auth --behind=pull # Pull base branch if behind upstream, then create
bt cd feature/auth # Jump to worktree
bt ls # List all worktrees
bt rm feature/auth # Remove when done
Expand Down Expand Up @@ -326,7 +328,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, `--base` for base branch/commit, auto-fetches remotes) |
| `bt add <branch>` | Add worktree (`-b` for new branch, `--base` for base branch/commit, `--behind` for behind-upstream action, auto-fetches remotes) |
| `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
65 changes: 53 additions & 12 deletions cmd/bt/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
addDetach bool
addForce bool
addNoFetch bool
addBehind string
)

var addCmd = &cobra.Command{
Expand All @@ -42,13 +43,20 @@ Branch resolution order:
2. origin/<branch> exists -> create tracking branch
3. <remote>/<branch> format -> use specified remote

When the base branch is behind its upstream, an interactive prompt lets you choose:
1. Continue without pulling (use as-is)
2. Pull (fast-forward) and then continue
3. Abort
Use --behind=continue|pull|abort to skip the prompt.

Examples:
bt add -b feature/auth # Creates new branch and worktree
bt add -b feature/new --base abc123 # Creates new branch based on a commit
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 --no-fetch feature/new # Skip auto-fetch from remotes`,
bt add --no-fetch feature/new # Skip auto-fetch from remotes
bt add -b feature/new --behind=pull # Pull base branch if behind, then create`,
Args: cobra.ExactArgs(1),
RunE: runAdd,
}
Expand All @@ -59,6 +67,7 @@ func init() {
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(&addNoFetch, "no-fetch", false, "Skip auto-fetch from remotes")
addCmd.Flags().StringVar(&addBehind, "behind", "", "Action when base branch is behind upstream: continue, pull, abort")
}

func runAdd(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -161,6 +170,11 @@ func runAdd(cmd *cobra.Command, args []string) error {
}
}

// Validate --behind flag value
if addBehind != "" && addBehind != "continue" && addBehind != "pull" && addBehind != "abort" {
return fmt.Errorf("invalid value for --behind: %q (must be 'continue', 'pull', or 'abort')", addBehind)
}

// Upstream behind detection
if !addForce {
var branchToCheck string
Expand All @@ -184,18 +198,45 @@ func runAdd(cmd *cobra.Command, args []string) error {
if branchToCheck != "" {
behindCount, _ := wtMgr.Executor.GetUpstreamBehindCount(branchToCheck)
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')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
return fmt.Errorf("aborted: '%s' is behind upstream", branchToCheck)
fmt.Printf("Warning: '%s' is %d commit(s) behind its upstream.\n", branchToCheck, behindCount)

action := addBehind
if action == "" {
if isTerminal() {
// Interactive: show 3-choice prompt
fmt.Println("Choose action:")
fmt.Println(" [1] Continue without pulling (use as-is)")
fmt.Println(" [2] Pull (fast-forward) and then continue")
fmt.Println(" [3] Abort")
fmt.Printf("Select [1/2/3]: ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
switch response {
case "1":
action = "continue"
case "2":
action = "pull"
default:
action = "abort"
}
} else {
// Non-TTY: warn but proceed
action = "continue"
}
}

switch action {
case "continue":
// Proceed as-is
case "pull":
fmt.Printf("Pulling '%s'...\n", branchToCheck)
if err := wtMgr.Executor.PullBranch(branchToCheck); err != nil {
return fmt.Errorf("failed to pull '%s': %w", branchToCheck, err)
}
} else {
// Non-TTY: warn but proceed
fmt.Printf("Warning: '%s' is %d commit(s) behind its upstream (non-interactive, proceeding).\n", branchToCheck, behindCount)
fmt.Printf("'%s' is now up to date.\n", branchToCheck)
case "abort":
return fmt.Errorf("aborted: '%s' is behind upstream", branchToCheck)
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,18 @@ Remote branch operation tests.
| `TestAddRemoteBranchExplicit` | Explicit addition with `origin/branch` format |
| `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 |
| `TestAddUpstreamBehindWarning` | Warning when local branch is behind its upstream (non-TTY continues by default) |
| `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) |
| `TestAddNewBranchWithLocalBase` | `--base` with local branch works correctly |
| `TestAddNewBranchWithNonexistentBase` | Error when `--base` specifies non-existent branch |
| `TestAddNewBranchShowsBaseInfo` | Display of base branch information (HEAD fallback) |
| `TestAddNewBranchWithCommitBase` | `--base` with commit hash (full and short) works correctly |
| `TestAddBehindFlagContinue` | `--behind=continue` proceeds with warning when behind |
| `TestAddBehindFlagPull` | `--behind=pull` pulls base branch and then creates worktree |
| `TestAddBehindFlagAbort` | `--behind=abort` aborts when base branch is behind |
| `TestAddBehindFlagInvalid` | Invalid `--behind` value shows error |

### journey_rename_test.go

Expand Down
145 changes: 139 additions & 6 deletions e2e/journey_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,14 +281,14 @@ func TestAddUpstreamBehindWarning(t *testing.T) {
cmd.Dir = workPath
_ = cmd.Run()

t.Run("aborts when default branch is behind upstream without force", func(t *testing.T) {
t.Run("behind=continue continues with warning when behind", 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")
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/behind-test", "--behind=continue")

// Should show warning about being behind
combined := stdout + stderr
assertOutputContains(t, combined, "Warning: 'main' is")
assertOutputContains(t, combined, "behind its upstream")
// Should show warning about being behind but still proceed
assertOutputContains(t, stdout, "Warning: 'main' is")
assertOutputContains(t, stdout, "behind its upstream")
assertOutputContains(t, stdout, "Worktree created")
})

t.Run("force skips behind check", func(t *testing.T) {
Expand Down Expand Up @@ -504,3 +504,136 @@ func TestAddNewBranchWithCommitBase(t *testing.T) {
assertOutputContains(t, stderr, "not found")
})
}

// setupBehindRepo creates a repository where the local main branch is behind its upstream.
// Returns (projectDir, bareDir).
func setupBehindRepo(t *testing.T, prefix string) (string, string) {
t.Helper()

tempDir := createTempDir(t, prefix)
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()

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

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

git commit errors are ignored here; a missing user config or empty index would make the test proceed without actually creating the extra commit.

Suggested change
_ = cmd.Run()
if err := cmd.Run(); err != nil {
t.Fatalf("failed to create extra commit in %s: %v", workPath, err)
}

Copilot uses AI. Check for mistakes.
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workPath
_ = cmd.Run()
Comment on lines +523 to +551

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

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

These steps ignore errors (checkout + write). If workPath isn’t in the expected state, WriteFile/git add/commit may fail silently and the behind scenario won’t actually be created.

Suggested change
_ = 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()
if err := cmd.Run(); err != nil {
t.Fatalf("failed to configure remote.origin.fetch: %v", err)
}
cmd = exec.Command("git", "fetch", "origin")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to fetch origin: %v", err)
}
// Set upstream for main branch
cmd = exec.Command("git", "config", "branch.main.remote", "origin")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to configure branch.main.remote: %v", err)
}
cmd = exec.Command("git", "config", "branch.main.merge", "refs/heads/main")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to configure branch.main.merge: %v", err)
}
// Push a new commit to origin after clone
workPath := filepath.Join(tempDir, "work")
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to checkout main in workPath %q: %v", workPath, err)
}
newFilePath := filepath.Join(workPath, "extra.txt")
if err := os.WriteFile(newFilePath, []byte("extra"), 0644); err != nil {
t.Fatalf("failed to write extra file %q: %v", newFilePath, err)
}
cmd = exec.Command("git", "add", ".")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to add files in workPath %q: %v", workPath, err)
}
cmd = exec.Command("git", "commit", "-m", "extra commit")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to commit extra change in workPath %q: %v", workPath, err)
}
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to push extra commit to origin main from %q: %v", workPath, err)
}

Copilot uses AI. Check for mistakes.

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

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

git push errors are ignored here; if the push fails, the local branch may not be behind its upstream and the rest of the test becomes unreliable.

Suggested change
_ = cmd.Run()
if err := cmd.Run(); err != nil {
t.Fatalf("failed to push new commit to origin: %v", err)
}

Copilot uses AI. Check for mistakes.
Comment on lines +523 to +551

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

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

This setup code ignores errors for key git operations (e.g., config/fetch). If any step fails, the test can proceed in an invalid state and fail later with confusing output. Please check Run() errors and fail fast (e.g., t.Fatalf).

Suggested change
_ = 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()
if err := cmd.Run(); err != nil {
t.Fatalf("failed to configure remote.origin.fetch in %s: %v", bareDir, err)
}
cmd = exec.Command("git", "fetch", "origin")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to fetch origin in %s: %v", bareDir, err)
}
// Set upstream for main branch
cmd = exec.Command("git", "config", "branch.main.remote", "origin")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to configure branch.main.remote in %s: %v", bareDir, err)
}
cmd = exec.Command("git", "config", "branch.main.merge", "refs/heads/main")
cmd.Dir = bareDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to configure branch.main.merge in %s: %v", bareDir, err)
}
// Push a new commit to origin after clone
workPath := filepath.Join(tempDir, "work")
cmd = exec.Command("git", "checkout", "main")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to checkout main in %s: %v", workPath, err)
}
newFilePath := filepath.Join(workPath, "extra.txt")
if err := os.WriteFile(newFilePath, []byte("extra"), 0644); err != nil {
t.Fatalf("failed to write extra.txt in %s: %v", workPath, err)
}
cmd = exec.Command("git", "add", ".")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to add files in %s: %v", workPath, err)
}
cmd = exec.Command("git", "commit", "-m", "extra commit")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to commit extra.txt in %s: %v", workPath, err)
}
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workPath
if err := cmd.Run(); err != nil {
t.Fatalf("failed to push main to origin from %s: %v", workPath, err)
}

Copilot uses AI. Check for mistakes.

return projectDir, bareDir
}

// TestAddBehindFlagContinue tests --behind=continue when base branch is behind
func TestAddBehindFlagContinue(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

projectDir, _ := setupBehindRepo(t, "behind-continue")

t.Run("behind=continue proceeds with warning", func(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/behind-continue", "--behind=continue")

assertOutputContains(t, stdout, "Warning: 'main' is")
assertOutputContains(t, stdout, "behind its upstream")
assertOutputContains(t, stdout, "Worktree created")
})
}

// TestAddBehindFlagPull tests --behind=pull when base branch is behind
func TestAddBehindFlagPull(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

projectDir, bareDir := setupBehindRepo(t, "behind-pull")

t.Run("behind=pull pulls and then creates worktree", func(t *testing.T) {
// Get commit count before pull
cmd := exec.Command("git", "rev-list", "--count", "main")
cmd.Dir = bareDir
beforeOutput, _ := cmd.Output()
beforeCount := strings.TrimSpace(string(beforeOutput))

Comment on lines +583 to +587

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

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

git rev-list --count errors are ignored; if this command fails, beforeCount may be empty and the test failure message becomes misleading. Capture err from Output() and fail the test if non-nil.

Copilot uses AI. Check for mistakes.
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/behind-pull", "--behind=pull")

assertOutputContains(t, stdout, "Warning: 'main' is")
assertOutputContains(t, stdout, "Pulling 'main'")
assertOutputContains(t, stdout, "is now up to date")
assertOutputContains(t, stdout, "Worktree created")

// Verify that main was actually pulled (commit count should increase)
cmd = exec.Command("git", "rev-list", "--count", "main")
cmd.Dir = bareDir
afterOutput, _ := cmd.Output()
afterCount := strings.TrimSpace(string(afterOutput))

Comment on lines +596 to +600

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

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

Same issue for the post-pull commit count: handle Output() errors so the test fails with a clear root cause when git rev-list can’t be executed.

Copilot uses AI. Check for mistakes.
if afterCount == beforeCount {
t.Errorf("expected main to be pulled (commit count unchanged: %s)", beforeCount)
}
})
}

// TestAddBehindFlagAbort tests --behind=abort when base branch is behind
func TestAddBehindFlagAbort(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

projectDir, _ := setupBehindRepo(t, "behind-abort")

t.Run("behind=abort aborts when behind", func(t *testing.T) {
stdout, stderr := runBtExpectError(t, projectDir, "add", "-b", "feat/behind-abort", "--behind=abort")

combined := stdout + stderr
assertOutputContains(t, combined, "Warning: 'main' is")
assertOutputContains(t, combined, "aborted")
})
}

// TestAddBehindFlagInvalid tests --behind with invalid value
func TestAddBehindFlagInvalid(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

tempDir := createTempDir(t, "behind-invalid")
runBtSuccess(t, tempDir, "repo", "init", "test-repo")
projectDir := filepath.Join(tempDir, "test-repo")

t.Run("invalid behind value shows error", func(t *testing.T) {
_, stderr := runBtExpectError(t, projectDir, "add", "-b", "feat/invalid", "--behind=invalid")

assertOutputContains(t, stderr, "invalid value for --behind")
})
}
Loading