Skip to content

feat: interactive prompt and --behind flag for upstream behind detection#12

Merged
amaya382 merged 1 commit into
mainfrom
feat/add-behind-prompt
Feb 17, 2026
Merged

feat: interactive prompt and --behind flag for upstream behind detection#12
amaya382 merged 1 commit into
mainfrom
feat/add-behind-prompt

Conversation

@amaya382

Copy link
Copy Markdown
Owner

Summary

  • When the base branch is behind its upstream during bt add, replace the simple y/N prompt with a 3-choice interactive prompt: continue (use as-is), pull (fast-forward), or abort
  • Add --behind=continue|pull|abort flag for non-interactive/scripted usage
  • Pull uses update-ref instead of git merge for bare repository compatibility
  • Non-TTY environments default to continue (warn and proceed)

Changed files

  • cmd/bt/add.go — new --behind flag, 3-choice interactive prompt, pull action
  • internal/git/branch.go — new PullBranch() using merge-base --is-ancestor + update-ref
  • e2e/journey_remote_test.go — E2E tests for --behind=continue|pull|abort and invalid value
  • e2e/README.md — test documentation
  • README.md — user-facing docs
  • examples/rules/ — agent rules template updates

Test plan

  • task build passes
  • task test (unit tests) passes
  • task test:e2e (E2E tests) passes
  • golangci-lint run ./... passes
  • New E2E tests: TestAddBehindFlagContinue, TestAddBehindFlagPull, TestAddBehindFlagAbort, TestAddBehindFlagInvalid

…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.
Copilot AI review requested due to automatic review settings February 17, 2026 14:02
@amaya382 amaya382 self-assigned this Feb 17, 2026
@amaya382 amaya382 merged commit 29a1dc1 into main Feb 17, 2026
7 checks passed
@amaya382 amaya382 deleted the feat/add-behind-prompt branch February 17, 2026 14:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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|abort and a 3-choice interactive prompt when the base branch is behind upstream.
  • Introduces Executor.PullBranch() that verifies fast-forward and advances the local ref via merge-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.

Comment thread internal/git/branch.go
if err != nil {
return fmt.Errorf("cannot fast-forward '%s': upstream has diverged", localBranch)
}

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.

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.

Suggested change
// 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)
}
}
}
}
}

Copilot uses AI. Check for mistakes.
- Bug fixes: `fix/<bug-name>`
- Temporary exploration: `task/<task-id>`

Note that worktree name can containe `/`.

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.

Typo: “containe” -> “contain”.

Suggested change
Note that worktree name can containe `/`.
Note that worktree name can contain `/`.

Copilot uses AI. Check for mistakes.
Comment on lines +523 to +551
_ = 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()

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.
_ = 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.Run()
cmd = exec.Command("git", "push", "origin", "main")
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 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 +596 to +600
cmd = exec.Command("git", "rev-list", "--count", "main")
cmd.Dir = bareDir
afterOutput, _ := cmd.Output()
afterCount := strings.TrimSpace(string(afterOutput))

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.
Comment thread internal/git/branch.go
Comment on lines +200 to +202
_, err = e.Execute("merge-base", "--is-ancestor", localBranch, localBranch+"@{u}")
if err != nil {
return fmt.Errorf("cannot fast-forward '%s': upstream has diverged", localBranch)

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.

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.

Suggested change
_, 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)

Copilot uses AI. Check for mistakes.
Comment thread internal/git/branch.go
Comment on lines +205 to +206
// Update the local branch ref to the upstream commit
_, err = e.Execute("update-ref", "refs/heads/"+localBranch, upstreamHash)

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.

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines +523 to +551
_ = 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()

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.
Comment on lines +583 to +587
cmd := exec.Command("git", "rev-list", "--count", "main")
cmd.Dir = bareDir
beforeOutput, _ := cmd.Output()
beforeCount := strings.TrimSpace(string(beforeOutput))

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants