Skip to content

Commit f791e69

Browse files
committed
fix: use branch upstream (not default branch) for --branch reviews
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 the default branch only when no upstream is configured. - Add git.GetUpstream(repoPath, ref). Returns ("", nil) when no @{upstream} is set, the resolved short name when the ref exists locally, and ("", *UpstreamMissingError) when the upstream is configured but the referenced ref does not resolve. The verification resolves the fully-qualified ref from branch.<name>.remote / branch.<name>.merge — refs/heads/<merge> for local-branch tracking (remote == "."), refs/remotes/<remote>/<merge> otherwise — so a lookalike tag or local branch with the same short name cannot shadow a missing remote-tracking ref. Callers in analyze/review/refine surface UpstreamMissingError to the user so fork workflows don't silently analyze the wrong commit range; the post-commit hook path (tryBranchReview) treats it as "skip" since hooks must never block commits. - Add git.IsOnBaseBranch(repoPath, currentBranch, base), replacing the "currentBranch == LocalBranchName(base)" check. A slash- containing base is only treated as a remote-tracking ref when refs/remotes/<base> resolves, refs/heads/<base> does not, and the prefix matches one of the repository's configured remote names (longest match wins). That handles non-origin remotes (upstream/main), multi-slash remote names (company/fork/main), local-branch upstreams (git branch -u <local>), and avoids misclassifying local branches with slashes (feature/foo alongside a "feature" remote, or even a local branch literally named origin/foo). - refine's validateRefineContext only resolves @{upstream} when --since is empty, so an unresolvable upstream doesn't block an otherwise-valid --since invocation. - Add refExists, listRemotes, readGitConfig, and readUpstreamConfig helpers used by the above. Tests: - TestGetUpstream: no upstream, remote tracking branch, named ref, empty ref defaults to HEAD, local-branch upstream, missing-ref surfaces UpstreamMissingError, configured-but-never-fetched tracking, and a colliding local ref at the same short name as a missing remote-tracking ref. - TestIsOnBaseBranch: bare local, origin/-prefixed, non-origin remote prefix, multi-slash remote name, the "feature/foo" local-branch trap, the pathological local "origin/foo", and ambiguous refs. - TestTryBranchReview: prefers branch upstream over default branch, skips when upstream configured but unresolvable, blocks local main tracking non-origin upstream. - TestGetBranchFiles: prefers non-origin upstream, blocks on local main tracking non-origin upstream. - TestValidateRefineContext: prefers non-origin upstream, refuses local main tracking non-origin upstream, --since bypasses upstream resolution, UpstreamMissingError surfaces without --since.
1 parent d9aafdf commit f791e69

9 files changed

Lines changed: 766 additions & 22 deletions

File tree

cmd/roborev/analyze.go

Lines changed: 16 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,19 @@ 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 so "ahead of upstream"
973+
// semantics match `git status` and avoid pulling in commits that
974+
// were merged to the parent branch upstream of this one.
975+
upstream, uerr := git.GetUpstream(repoRoot, targetRef)
976+
var missing *git.UpstreamMissingError
977+
if errors.As(uerr, &missing) {
978+
return nil, fmt.Errorf("%w (or pass --base <ref>)", missing)
979+
}
980+
if uerr == nil && upstream != "" {
981+
base = upstream
982+
}
983+
}
970984
if base == "" {
971985
var err error
972986
base, err = git.GetDefaultBranch(repoRoot)
@@ -978,8 +992,8 @@ func getBranchFiles(cmd *cobra.Command, repoRoot string, opts analyzeOptions) (m
978992
// Validate not on base branch (only when analyzing current branch)
979993
if targetRef == "HEAD" {
980994
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))
995+
if git.IsOnBaseBranch(repoRoot, currentBranch, base) {
996+
return nil, fmt.Errorf("already on %s - switch to a feature branch first", currentBranch)
983997
}
984998
}
985999

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: 38 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,51 @@ func validateRefineContext(
281278
)
282279
}
283280
} else {
284-
if currentBranch == git.LocalBranchName(defaultBranch) {
281+
// Prefer the current branch's upstream tracking ref (matches
282+
// `git status` "ahead of upstream" semantics); fall back to the
283+
// repository default branch when no upstream is configured.
284+
upstream, uerr := git.GetUpstream(repoPath, "HEAD")
285+
var missing *git.UpstreamMissingError
286+
if errors.As(uerr, &missing) {
287+
return "", "", "", "",
288+
fmt.Errorf(
289+
"%w (run 'git fetch' or pass --since)", missing,
290+
)
291+
}
292+
if uerr == nil && upstream != "" {
293+
base = upstream
294+
}
295+
if base == "" {
296+
base, err = git.GetDefaultBranch(repoPath)
297+
if err != nil {
298+
return "", "", "", "",
299+
fmt.Errorf(
300+
"cannot determine default branch: %w", err,
301+
)
302+
}
303+
}
304+
305+
if git.IsOnBaseBranch(repoPath, currentBranch, base) {
285306
return "", "", "", "", fmt.Errorf(
286307
"refusing to refine on %s branch "+
287308
"without --since flag",
288-
git.LocalBranchName(defaultBranch),
309+
currentBranch,
289310
)
290311
}
291312

292313
mergeBase, err = git.GetMergeBase(
293-
repoPath, defaultBranch, "HEAD",
314+
repoPath, base, "HEAD",
294315
)
295316
if err != nil {
296317
return "", "", "", "",
297318
fmt.Errorf(
298319
"cannot find merge-base with %s: %w",
299-
defaultBranch, err,
320+
base, err,
300321
)
301322
}
302323
}
303324

304-
return repoPath, currentBranch, defaultBranch, mergeBase, nil
325+
return repoPath, currentBranch, base, mergeBase, nil
305326
}
306327

307328
// RunContext encapsulates the runtime context for the refine command,
@@ -314,7 +335,7 @@ type RunContext struct {
314335

315336
func runRefine(ctx RunContext, opts refineOptions) error {
316337
// 1. Validate git and branch context (before touching daemon)
317-
repoPath, currentBranch, defaultBranch, mergeBase, err := validateRefineContext(
338+
repoPath, currentBranch, base, mergeBase, err := validateRefineContext(
318339
ctx.WorkingDir, opts.since, opts.branch,
319340
)
320341
if err != nil {
@@ -368,7 +389,7 @@ func runRefine(ctx RunContext, opts refineOptions) error {
368389
if opts.since != "" {
369390
fmt.Printf("Refining commits since %s on branch %q\n", git.ShortSHA(mergeBase), currentBranch)
370391
} else {
371-
fmt.Printf("Refining branch %q (diverged from %s at %s)\n", currentBranch, defaultBranch, git.ShortSHA(mergeBase))
392+
fmt.Printf("Refining branch %q (diverged from %s at %s)\n", currentBranch, base, git.ShortSHA(mergeBase))
372393
}
373394

374395
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)