Skip to content

Commit e71a50e

Browse files
authored
fix: use branch upstream (not default branch) for --branch reviews (#660)
## Summary - `review --branch`, `tryBranchReview`, `analyze --branch`, and `refine` resolved the merge-base against the repository's default branch (typically `origin/main`). In fork workflows where the current branch's actual upstream (e.g. `upstream/main`) is ahead of `origin/main`, this pulled in commits that were already merged upstream. Each command now prefers `@{upstream}` of the current branch and falls back to `GetDefaultBranch()` only when no upstream is configured. - Added `git.GetUpstream(repoPath, ref)` (returns the upstream tracking branch via `git rev-parse --abbrev-ref <ref>@{upstream}`, empty on no upstream). - Replaced the `currentBranch == LocalBranchName(base)` guardrails with `git.IsOnBaseBranch(repoPath, currentBranch, base)`, which generalizes the `origin/` shortcut by verifying `refs/remotes/<base>` exists before stripping the remote prefix — so non-origin remotes are handled and local branches with slashes (e.g. `feature/foo`) are not misclassified. - New tests: `TestGetUpstream`, `TestIsOnBaseBranch`, and `TestTryBranchReview` cases for the non-origin upstream scenario and for the feature/foo misclassification guard. Co-authored-by: Phillip Cloud <cpcloud@users.noreply.github.com>
1 parent 1bc7b4a commit e71a50e

9 files changed

Lines changed: 1034 additions & 23 deletions

File tree

cmd/roborev/analyze.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"net/http"
@@ -967,6 +968,25 @@ func getBranchFiles(cmd *cobra.Command, repoRoot string, opts analyzeOptions) (m
967968

968969
// Determine base branch
969970
base := opts.baseBranch
971+
if base == "" {
972+
// Prefer the branch's upstream tracking ref only when it resolves to
973+
// a trunk-named branch (e.g., local main tracking upstream/main in a
974+
// fork). A branch tracking its own remote counterpart (feature
975+
// tracking origin/feature) is not trunk — using it would skip
976+
// already-pushed feature commits, contradicting "--branch analyzes
977+
// all commits since trunk".
978+
upstream, uerr := git.GetUpstream(repoRoot, targetRef)
979+
var missing *git.UpstreamMissingError
980+
if errors.As(uerr, &missing) {
981+
return nil, fmt.Errorf("%w (or pass --base <ref>)", missing)
982+
}
983+
if uerr != nil {
984+
return nil, fmt.Errorf("resolve upstream for %s: %w (pass --base <ref> to skip)", targetRef, uerr)
985+
}
986+
if upstream != "" && git.UpstreamIsTrunk(repoRoot, targetRef) {
987+
base = upstream
988+
}
989+
}
970990
if base == "" {
971991
var err error
972992
base, err = git.GetDefaultBranch(repoRoot)
@@ -978,8 +998,8 @@ func getBranchFiles(cmd *cobra.Command, repoRoot string, opts analyzeOptions) (m
978998
// Validate not on base branch (only when analyzing current branch)
979999
if targetRef == "HEAD" {
9801000
currentBranch := git.GetCurrentBranch(repoRoot)
981-
if currentBranch == git.LocalBranchName(base) {
982-
return nil, fmt.Errorf("already on %s - switch to a feature branch first", git.LocalBranchName(base))
1001+
if git.IsOnBaseBranch(repoRoot, currentBranch, base) {
1002+
return nil, fmt.Errorf("already on %s - switch to a feature branch first", currentBranch)
9831003
}
9841004
}
9851005

cmd/roborev/analyze_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,51 @@ func TestGetBranchFiles(t *testing.T) {
570570
require.Error(t, err, "expected error when on base branch")
571571
assert.Contains(t, err.Error(), "already on main", "unexpected error")
572572
})
573+
574+
t.Run("prefers non-origin upstream as base", func(t *testing.T) {
575+
// Fork workflow: origin (stale fork) and upstream (real repo).
576+
// Feature branch tracks upstream/main; merge-base must be taken
577+
// against upstream/main, not origin/main, so commits already
578+
// merged upstream aren't re-analyzed.
579+
remote := newBareTestGitRepo(t)
580+
stale := newBareTestGitRepo(t)
581+
repo := newTestGitRepo(t)
582+
repo.Run("symbolic-ref", "HEAD", "refs/heads/main")
583+
repo.CommitFile("a.go", "package main", "u1")
584+
repo.CommitFile("b.go", "package main", "u2")
585+
repo.Run("remote", "add", "upstream", remote.Dir)
586+
repo.Run("push", "-u", "upstream", "main")
587+
588+
// origin lags upstream by 1 commit.
589+
repo.Run("remote", "add", "origin", stale.Dir)
590+
repo.Run("push", "origin", "HEAD~1:refs/heads/main")
591+
repo.Run("fetch", "origin")
592+
repo.Run("remote", "set-head", "origin", "main")
593+
594+
repo.Run("checkout", "-b", "feature", "--track", "upstream/main")
595+
repo.CommitFile("only-new.go", "package main\nfunc New() {}", "feature work")
596+
597+
files, err := getBranchFiles(cmd, repo.Dir, analyzeOptions{branch: "HEAD"})
598+
require.NoError(t, err)
599+
// b.go was already merged to upstream/main; must not be analyzed.
600+
assert.NotContains(t, files, "b.go",
601+
"already-merged upstream commit must not be analyzed")
602+
assert.Contains(t, files, "only-new.go",
603+
"new feature file should be analyzed")
604+
})
605+
606+
t.Run("blocks local main tracking non-origin upstream", func(t *testing.T) {
607+
remote := newBareTestGitRepo(t)
608+
repo := newTestGitRepo(t)
609+
repo.Run("symbolic-ref", "HEAD", "refs/heads/main")
610+
repo.CommitFile("a.go", "package main", "initial")
611+
repo.Run("remote", "add", "upstream", remote.Dir)
612+
repo.Run("push", "-u", "upstream", "main")
613+
614+
_, err := getBranchFiles(cmd, repo.Dir, analyzeOptions{branch: "HEAD"})
615+
require.Error(t, err, "expected error when on base branch")
616+
assert.Contains(t, err.Error(), "already on main", "unexpected error")
617+
})
573618
}
574619

575620
func TestAnalyzeBranchSpaceSeparated(t *testing.T) {

cmd/roborev/helpers_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ func newTestGitRepo(t *testing.T) *TestGitRepo {
4343
return r
4444
}
4545

46+
// newBareTestGitRepo creates a bare git repository for use as a remote.
47+
func newBareTestGitRepo(t *testing.T) *TestGitRepo {
48+
t.Helper()
49+
if _, err := exec.LookPath("git"); err != nil {
50+
t.Skip("git not available")
51+
}
52+
dir := t.TempDir()
53+
resolved, err := filepath.EvalSymlinks(dir)
54+
require.NoError(t, err, "Failed to resolve symlinks: %v")
55+
56+
r := &TestGitRepo{Dir: resolved, t: t}
57+
r.Run("init", "--bare")
58+
return r
59+
}
60+
4661
// chdir changes to dir and registers a t.Cleanup to restore the original directory.
4762
func chdir(t *testing.T, dir string) {
4863
t.Helper()

cmd/roborev/refine.go

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,14 @@ func (t *stepTimer) stopLive() {
207207
}
208208

209209
// validateRefineContext validates git and branch preconditions for refine.
210-
// Returns repoPath, currentBranch, defaultBranch, mergeBase, or an error.
210+
// Returns repoPath, currentBranch, base, mergeBase, or an error.
211+
// base is the ref that HEAD was diverged from — the branch's upstream
212+
// tracking ref when configured, otherwise the repository's default branch.
211213
// If branchFlag is non-empty, validates that the user is on that branch.
212214
// This validation happens before any daemon interaction.
213215
func validateRefineContext(
214216
cwd, since, branchFlag string,
215-
) (repoPath, currentBranch, defaultBranch, mergeBase string, err error) {
217+
) (repoPath, currentBranch, base, mergeBase string, err error) {
216218
repoPath, err = git.GetRepoRoot(cwd)
217219
if err != nil {
218220
return "", "", "", "",
@@ -235,14 +237,6 @@ func validateRefineContext(
235237
)
236238
}
237239

238-
defaultBranch, err = git.GetDefaultBranch(repoPath)
239-
if err != nil {
240-
return "", "", "", "",
241-
fmt.Errorf(
242-
"cannot determine default branch: %w", err,
243-
)
244-
}
245-
246240
currentBranch = git.GetCurrentBranch(repoPath)
247241

248242
// --branch: validate the user is on the expected branch
@@ -255,6 +249,9 @@ func validateRefineContext(
255249
}
256250

257251
if since != "" {
252+
// --since provides an explicit merge base, so upstream/default-branch
253+
// resolution is unnecessary. Skip it so a misconfigured or unfetched
254+
// upstream doesn't block an otherwise-valid --since invocation.
258255
mergeBase, err = git.ResolveSHA(repoPath, since)
259256
if err != nil {
260257
return "", "", "", "",
@@ -281,27 +278,58 @@ func validateRefineContext(
281278
)
282279
}
283280
} else {
284-
if currentBranch == git.LocalBranchName(defaultBranch) {
281+
// Prefer the current branch's upstream tracking ref only when it
282+
// resolves to a trunk-named branch (e.g., local main tracking
283+
// upstream/main in a fork). A branch tracking its own remote
284+
// counterpart is not trunk — use GetDefaultBranch instead.
285+
upstream, uerr := git.GetUpstream(repoPath, "HEAD")
286+
var missing *git.UpstreamMissingError
287+
if errors.As(uerr, &missing) {
288+
return "", "", "", "",
289+
fmt.Errorf(
290+
"%w (run 'git fetch' or pass --since)", missing,
291+
)
292+
}
293+
if uerr != nil {
294+
return "", "", "", "",
295+
fmt.Errorf(
296+
"resolve upstream for HEAD: %w (pass --since to skip)", uerr,
297+
)
298+
}
299+
if upstream != "" && git.UpstreamIsTrunk(repoPath, "HEAD") {
300+
base = upstream
301+
}
302+
if base == "" {
303+
base, err = git.GetDefaultBranch(repoPath)
304+
if err != nil {
305+
return "", "", "", "",
306+
fmt.Errorf(
307+
"cannot determine default branch: %w", err,
308+
)
309+
}
310+
}
311+
312+
if git.IsOnBaseBranch(repoPath, currentBranch, base) {
285313
return "", "", "", "", fmt.Errorf(
286314
"refusing to refine on %s branch "+
287315
"without --since flag",
288-
git.LocalBranchName(defaultBranch),
316+
currentBranch,
289317
)
290318
}
291319

292320
mergeBase, err = git.GetMergeBase(
293-
repoPath, defaultBranch, "HEAD",
321+
repoPath, base, "HEAD",
294322
)
295323
if err != nil {
296324
return "", "", "", "",
297325
fmt.Errorf(
298326
"cannot find merge-base with %s: %w",
299-
defaultBranch, err,
327+
base, err,
300328
)
301329
}
302330
}
303331

304-
return repoPath, currentBranch, defaultBranch, mergeBase, nil
332+
return repoPath, currentBranch, base, mergeBase, nil
305333
}
306334

307335
// RunContext encapsulates the runtime context for the refine command,
@@ -314,7 +342,7 @@ type RunContext struct {
314342

315343
func runRefine(ctx RunContext, opts refineOptions) error {
316344
// 1. Validate git and branch context (before touching daemon)
317-
repoPath, currentBranch, defaultBranch, mergeBase, err := validateRefineContext(
345+
repoPath, currentBranch, base, mergeBase, err := validateRefineContext(
318346
ctx.WorkingDir, opts.since, opts.branch,
319347
)
320348
if err != nil {
@@ -368,7 +396,7 @@ func runRefine(ctx RunContext, opts refineOptions) error {
368396
if opts.since != "" {
369397
fmt.Printf("Refining commits since %s on branch %q\n", git.ShortSHA(mergeBase), currentBranch)
370398
} else {
371-
fmt.Printf("Refining branch %q (diverged from %s at %s)\n", currentBranch, defaultBranch, git.ShortSHA(mergeBase))
399+
fmt.Printf("Refining branch %q (diverged from %s at %s)\n", currentBranch, base, git.ShortSHA(mergeBase))
372400
}
373401

374402
allowUnsafe := resolveAllowUnsafeAgents(opts.allowUnsafeAgents, opts.unsafeFlagChanged, cfg)

cmd/roborev/refine_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/roborev-dev/roborev/internal/agent"
1212
"github.com/roborev-dev/roborev/internal/config"
1313
"github.com/roborev-dev/roborev/internal/daemon"
14+
"github.com/roborev-dev/roborev/internal/git"
1415
"github.com/roborev-dev/roborev/internal/storage"
1516
"github.com/roborev-dev/roborev/internal/testutil"
1617
"github.com/stretchr/testify/assert"
@@ -563,6 +564,54 @@ func TestValidateRefineContext_AllowsMainBranchWithSince(t *testing.T) {
563564
assert.Equal(t, mergeBase, baseSHA)
564565
}
565566

567+
func TestValidateRefineContext_SinceIgnoresUpstreamMissing(t *testing.T) {
568+
if _, err := exec.LookPath("git"); err != nil {
569+
t.Skip("git not available")
570+
}
571+
572+
// --since provides the merge base explicitly, so an unfetched or
573+
// missing @{upstream} must not block a valid --since invocation.
574+
repo := testutil.InitTestRepo(t)
575+
baseSHA := repo.RevParse("HEAD")
576+
repo.CommitFile("second.txt", "second", "second commit")
577+
578+
// Configure tracking against an upstream that never resolves locally.
579+
repo.Run("config", "branch.main.remote", "upstream")
580+
repo.Run("config", "branch.main.merge", "refs/heads/main")
581+
582+
chdirForTest(t, repo.Root)
583+
584+
repoPath, currentBranch, _, mergeBase, err := validateRefineContext("", baseSHA, "")
585+
require.NoError(t, err, "--since should bypass upstream resolution: %v", err)
586+
587+
assert.NotEmpty(t, repoPath)
588+
assert.Equal(t, "main", currentBranch)
589+
assert.Equal(t, baseSHA, mergeBase)
590+
}
591+
592+
func TestValidateRefineContext_UpstreamMissingErrorsWithoutSince(t *testing.T) {
593+
if _, err := exec.LookPath("git"); err != nil {
594+
t.Skip("git not available")
595+
}
596+
597+
// Without --since, a configured-but-unfetched upstream must surface
598+
// as UpstreamMissingError rather than silently falling back to the
599+
// repository default branch (which could yield the wrong range).
600+
repo := testutil.InitTestRepo(t)
601+
repo.RunGit("checkout", "-b", "feature")
602+
repo.CommitFile("feature.txt", "feature", "feature commit")
603+
repo.Run("config", "branch.feature.remote", "upstream")
604+
repo.Run("config", "branch.feature.merge", "refs/heads/main")
605+
606+
chdirForTest(t, repo.Root)
607+
608+
_, _, _, _, err := validateRefineContext("", "", "")
609+
require.Error(t, err, "expected missing-upstream error without --since")
610+
611+
var missing *git.UpstreamMissingError
612+
assert.ErrorAs(t, err, &missing, "expected UpstreamMissingError, got: %v", err)
613+
}
614+
566615
func TestValidateRefineContext_SinceWorksOnFeatureBranch(t *testing.T) {
567616
if _, err := exec.LookPath("git"); err != nil {
568617
t.Skip("git not available")
@@ -619,6 +668,69 @@ func TestValidateRefineContext_SinceNotAncestorOfHEAD(t *testing.T) {
619668
assert.Contains(t, err.Error(), "not an ancestor of HEAD")
620669
}
621670

671+
func TestValidateRefineContext_PrefersNonOriginUpstream(t *testing.T) {
672+
if _, err := exec.LookPath("git"); err != nil {
673+
t.Skip("git not available")
674+
}
675+
676+
// Fork workflow: origin (stale fork) lags behind upstream (real repo).
677+
// Feature branch tracks upstream/main. validateRefineContext must
678+
// pick upstream/main as the base so commits already merged upstream
679+
// are not refined.
680+
repo := testutil.InitTestRepo(t)
681+
repo.CommitFile("upstream_c2.go", "package main", "upstream c2")
682+
upstreamSHA := repo.RevParse("HEAD")
683+
684+
bareRemote := t.TempDir()
685+
bareStale := t.TempDir()
686+
repo.Run("init", "--bare", bareRemote)
687+
repo.Run("init", "--bare", bareStale)
688+
repo.Run("remote", "add", "upstream", bareRemote)
689+
repo.Run("push", "-u", "upstream", "main")
690+
691+
repo.Run("remote", "add", "origin", bareStale)
692+
repo.Run("push", "origin", "HEAD~1:refs/heads/main")
693+
repo.Run("fetch", "origin")
694+
repo.Run("remote", "set-head", "origin", "main")
695+
696+
repo.RunGit("checkout", "-b", "feature", "--track", "upstream/main")
697+
repo.CommitFile("feature.go", "package main", "feature commit")
698+
699+
chdirForTest(t, repo.Root)
700+
701+
repoPath, currentBranch, base, mergeBase, err := validateRefineContext("", "", "")
702+
require.NoError(t, err, "validation should pass on feature tracking upstream/main")
703+
704+
assert.NotEmpty(t, repoPath)
705+
assert.Equal(t, "feature", currentBranch)
706+
assert.Equal(t, "upstream/main", base,
707+
"base must resolve to the branch's upstream, not origin/main")
708+
assert.Equal(t, upstreamSHA, mergeBase,
709+
"merge-base must be upstream/main's tip so already-merged commits aren't refined")
710+
}
711+
712+
func TestValidateRefineContext_RefusesLocalMainTrackingNonOriginUpstream(t *testing.T) {
713+
if _, err := exec.LookPath("git"); err != nil {
714+
t.Skip("git not available")
715+
}
716+
717+
// Local main tracking upstream/main must still hit the "refusing to
718+
// refine on main" guardrail even though base is "upstream/main"
719+
// (not "origin/main"). Regression for IsOnBaseBranch's non-origin
720+
// remote handling.
721+
repo := testutil.InitTestRepo(t)
722+
bareRemote := t.TempDir()
723+
repo.Run("init", "--bare", bareRemote)
724+
repo.Run("remote", "add", "upstream", bareRemote)
725+
repo.Run("push", "-u", "upstream", "main")
726+
727+
chdirForTest(t, repo.Root)
728+
729+
_, _, _, _, err := validateRefineContext("", "", "")
730+
require.Error(t, err, "expected refuse-on-base error for main tracking upstream/main")
731+
assert.Contains(t, err.Error(), "refusing to refine on main")
732+
}
733+
622734
func TestValidateRefineContext_FeatureBranchWithoutSinceStillWorks(t *testing.T) {
623735
if _, err := exec.LookPath("git"); err != nil {
624736
t.Skip("git not available")

0 commit comments

Comments
 (0)