feat: interactive prompt and --behind flag for upstream behind detection#12
Conversation
…tection When the base branch is behind its upstream, bt add now shows a 3-choice interactive prompt (continue/pull/abort) instead of a simple y/N confirmation. Add --behind=continue|pull|abort flag to control behavior non-interactively. The pull action uses update-ref for bare repository compatibility.
There was a problem hiding this comment.
Pull request overview
Adds configurable handling when the base branch is behind its upstream during bt add, supporting both interactive and non-interactive workflows and enabling an optional fast-forward “pull” in baretree/bare-repo setups.
Changes:
- Adds
--behind=continue|pull|abortand a 3-choice interactive prompt when the base branch is behind upstream. - Introduces
Executor.PullBranch()that verifies fast-forward and advances the local ref viamerge-base --is-ancestor+update-ref. - Extends E2E coverage and updates user/docs/templates to document the new behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
cmd/bt/add.go |
Adds --behind flag, interactive prompt logic, and integrates optional pull/abort behavior into bt add. |
internal/git/branch.go |
Implements PullBranch() fast-forward logic intended to work in bare repositories. |
e2e/journey_remote_test.go |
Adds E2E tests for --behind modes and a helper to set up “behind upstream” scenarios. |
e2e/README.md |
Documents the new E2E test cases. |
README.md |
Documents --behind usage in examples and command table. |
examples/rules/working-directory-on-git-worktree-with-baretree.md |
Updates rules template to recommend bt add --behind=pull and adjusts guidance/naming. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if err != nil { | ||
| return fmt.Errorf("cannot fast-forward '%s': upstream has diverged", localBranch) | ||
| } | ||
|
|
There was a problem hiding this comment.
Using update-ref to advance refs/heads/<branch> won’t update any existing worktree that has that branch checked out (common for the default branch in baretree), potentially leaving the worktree’s files/index out of sync with the new HEAD. Consider detecting whether localBranch is checked out in a worktree and, if so, performing a fast-forward in that worktree (e.g., merge --ff-only/reset --hard) or clearly warning the user about the mismatch.
| // Before updating the branch ref directly, check if this branch is checked out | |
| // in any worktree. Advancing refs/heads/<branch> with update-ref does not update | |
| // existing worktrees and can leave them out of sync. If we detect that the branch | |
| // is checked out, return a clear error instead of silently updating the ref. | |
| fullRef, err := e.Execute("rev-parse", "--symbolic-full-name", localBranch) | |
| if err == nil { | |
| worktreeList, wtErr := e.Execute("worktree", "list", "--porcelain") | |
| if wtErr == nil { | |
| fullRef = strings.TrimSpace(fullRef) | |
| lines := strings.Split(worktreeList, "\n") | |
| for _, line := range lines { | |
| line = strings.TrimSpace(line) | |
| if strings.HasPrefix(line, "branch ") { | |
| branchRef := strings.TrimSpace(strings.TrimPrefix(line, "branch ")) | |
| if branchRef == fullRef || branchRef == "refs/heads/"+localBranch || branchRef == localBranch { | |
| return fmt.Errorf("cannot fast-forward '%s' using update-ref: branch is checked out in a worktree; please update it from that worktree instead", localBranch) | |
| } | |
| } | |
| } | |
| } | |
| } |
| - Bug fixes: `fix/<bug-name>` | ||
| - Temporary exploration: `task/<task-id>` | ||
|
|
||
| Note that worktree name can containe `/`. |
There was a problem hiding this comment.
Typo: “containe” -> “contain”.
| Note that worktree name can containe `/`. | |
| Note that worktree name can contain `/`. |
| _ = 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() |
There was a problem hiding this comment.
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.
| _ = 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) | |
| } |
| _ = cmd.Run() | ||
| cmd = exec.Command("git", "commit", "-m", "extra commit") | ||
| cmd.Dir = workPath | ||
| _ = cmd.Run() |
There was a problem hiding this comment.
git commit errors are ignored here; a missing user config or empty index would make the test proceed without actually creating the extra commit.
| _ = cmd.Run() | |
| if err := cmd.Run(); err != nil { | |
| t.Fatalf("failed to create extra commit in %s: %v", workPath, err) | |
| } |
| _ = cmd.Run() | ||
| cmd = exec.Command("git", "push", "origin", "main") | ||
| cmd.Dir = workPath | ||
| _ = cmd.Run() |
There was a problem hiding this comment.
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.
| _ = cmd.Run() | |
| if err := cmd.Run(); err != nil { | |
| t.Fatalf("failed to push new commit to origin: %v", err) | |
| } |
| cmd = exec.Command("git", "rev-list", "--count", "main") | ||
| cmd.Dir = bareDir | ||
| afterOutput, _ := cmd.Output() | ||
| afterCount := strings.TrimSpace(string(afterOutput)) | ||
|
|
There was a problem hiding this comment.
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.
| _, err = e.Execute("merge-base", "--is-ancestor", localBranch, localBranch+"@{u}") | ||
| if err != nil { | ||
| return fmt.Errorf("cannot fast-forward '%s': upstream has diverged", localBranch) |
There was a problem hiding this comment.
PullBranch already resolves localHash/upstreamHash, but the ancestry check re-reads refs via merge-base --is-ancestor <branch> <branch>@{u}. Using the resolved hashes instead keeps the operation consistent (and avoids an extra ref lookup) if refs change mid-operation; also consider wrapping the underlying error so non-divergence failures aren’t misreported.
| _, err = e.Execute("merge-base", "--is-ancestor", localBranch, localBranch+"@{u}") | |
| if err != nil { | |
| return fmt.Errorf("cannot fast-forward '%s': upstream has diverged", localBranch) | |
| _, err = e.Execute("merge-base", "--is-ancestor", localHash, upstreamHash) | |
| if err != nil { | |
| return fmt.Errorf("cannot fast-forward '%s': upstream has diverged: %w", localBranch, err) |
| // Update the local branch ref to the upstream commit | ||
| _, err = e.Execute("update-ref", "refs/heads/"+localBranch, upstreamHash) |
There was a problem hiding this comment.
update-ref supports providing the expected old value; passing localHash makes this fast-forward atomic and avoids clobbering unexpected concurrent updates to the branch ref.
| // Update the local branch ref to the upstream commit | |
| _, err = e.Execute("update-ref", "refs/heads/"+localBranch, upstreamHash) | |
| // Update the local branch ref to the upstream commit atomically: | |
| // use localHash as the expected old value to avoid clobbering concurrent updates. | |
| _, err = e.Execute("update-ref", "refs/heads/"+localBranch, upstreamHash, localHash) |
| _ = 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() |
There was a problem hiding this comment.
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).
| _ = 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) | |
| } |
| cmd := exec.Command("git", "rev-list", "--count", "main") | ||
| cmd.Dir = bareDir | ||
| beforeOutput, _ := cmd.Output() | ||
| beforeCount := strings.TrimSpace(string(beforeOutput)) | ||
|
|
There was a problem hiding this comment.
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.
Summary
bt add, replace the simple y/N prompt with a 3-choice interactive prompt: continue (use as-is), pull (fast-forward), or abort--behind=continue|pull|abortflag for non-interactive/scripted usageupdate-refinstead ofgit mergefor bare repository compatibilitycontinue(warn and proceed)Changed files
cmd/bt/add.go— new--behindflag, 3-choice interactive prompt, pull actioninternal/git/branch.go— newPullBranch()usingmerge-base --is-ancestor+update-refe2e/journey_remote_test.go— E2E tests for--behind=continue|pull|abortand invalid valuee2e/README.md— test documentationREADME.md— user-facing docsexamples/rules/— agent rules template updatesTest plan
task buildpassestask test(unit tests) passestask test:e2e(E2E tests) passesgolangci-lint run ./...passesTestAddBehindFlagContinue,TestAddBehindFlagPull,TestAddBehindFlagAbort,TestAddBehindFlagInvalid