-
Notifications
You must be signed in to change notification settings - Fork 0
feat: auto-fetch from remotes on bt add #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
|
|
@@ -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 | ||
|
|
@@ -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, | ||
| } | ||
|
|
@@ -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 { | ||
|
|
@@ -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) | ||
|
|
@@ -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 { | ||
|
|
@@ -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) | ||
| } | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
| 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) | ||
| } | ||
| } 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") | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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") | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
@@ -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") | ||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||
| _, 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
AI
Feb 16, 2026
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error returned by
GetUpstreamBehindCountis 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.