Skip to content
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,28 @@ ocr review --from main --to feature-branch
ocr review --commit abc123
```

#### Reviewing a fork against upstream

When you work on a fork and want to review what your current branch changed
relative to the original repository, use `--upstream`:

```bash
# Against a configured remote (auto-fetches its default branch):
ocr review --upstream upstream

# Against the original repo by URL, without configuring a remote:
ocr review --upstream https://github.com/orig/repo

# Pick a specific upstream branch; default is HEAD vs upstream's default branch:
ocr review --upstream upstream --upstream-branch main

# Offline: use the already-fetched remote-tracking branch, no network:
ocr review --upstream upstream --no-fetch
```

`--upstream` reviews `merge-base(upstream, HEAD)..HEAD` — i.e. exactly the
commits your branch adds on top of upstream. Override the target with `--to`.

### Integrate with Coding Agents

OCR can be seamlessly integrated into AI coding agents as a slash command, enabling code review directly within your agent workflow.
Expand Down Expand Up @@ -307,6 +329,9 @@ See the [`examples/`](./examples/) directory for integration examples:
| `--from` | — | — | Source ref (e.g., `main`) |
| `--to` | — | — | Target ref (e.g., `feature-branch`) |
| `--commit` | `-c` | — | Single commit to review |
| `--upstream` | — | — | Upstream remote name or git URL to review the current branch against (fork workflow); reviews `merge-base(upstream, HEAD)..HEAD` |
| `--upstream-branch` | — | upstream default | Branch on `--upstream` to compare against |
| `--no-fetch` | — | `false` | With `--upstream`: skip network fetch, use the local remote-tracking ref |
| `--preview` | `-p` | `false` | Preview which files will be reviewed without running the LLM |
| `--format` | `-f` | `text` | Output format: `text` or `json` |
| `--concurrency` | — | `8` | Max concurrent file reviews |
Expand Down
27 changes: 26 additions & 1 deletion cmd/opencodereview/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ type reviewOptions struct {
from string
to string
commit string
upstream string // --upstream: remote name or git URL to review against (fork workflow)
upstreamBranch string // --upstream-branch: branch on --upstream (default: its default branch)
noFetch bool // --no-fetch: with --upstream, skip network fetch and use local ref
outputFormat string
audience string // --audience: "human" (default) or "agent"
background string // --background: optional requirement context
Expand Down Expand Up @@ -131,6 +134,9 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)")
a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
a.BoolVarP(&opts.preview, "preview", "p", false, "preview which files will be reviewed without running the LLM")
a.StringVar(&opts.upstream, "upstream", "", "upstream remote name or git URL to review the current branch against (fork workflow)")
a.StringVar(&opts.upstreamBranch, "upstream-branch", "", "branch on --upstream to compare against (default: upstream's default branch)")
a.BoolVar(&opts.noFetch, "no-fetch", false, "with --upstream: skip network fetch and use the local remote-tracking ref")

if err := a.Parse(args); err != nil {
return opts, fmt.Errorf("parse flags: %w", err)
Expand All @@ -152,6 +158,18 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
if modeCount > 1 {
return opts, fmt.Errorf("only one review mode allowed (--from/--to or --commit)")
}
if opts.upstream != "" {
if opts.from != "" || opts.commit != "" {
return opts, fmt.Errorf("--upstream cannot be combined with --from or --commit")
}
} else {
if opts.upstreamBranch != "" {
return opts, fmt.Errorf("--upstream-branch requires --upstream")
}
if opts.noFetch {
return opts, fmt.Errorf("--no-fetch requires --upstream")
}
}
if opts.from != "" && opts.to == "" {
return opts, fmt.Errorf("--to is required when --from is specified")
}
Expand Down Expand Up @@ -196,6 +214,10 @@ Examples:
ocr review --commit abc123
ocr review -c abc123

# Review current branch against an upstream repo (fork workflow)
ocr review --upstream upstream
ocr review --upstream https://github.com/orig/repo --upstream-branch main

# Output JSON format
ocr review --format json
ocr review -f json
Expand All @@ -221,7 +243,10 @@ Flags:
--rule string path to JSON file with system review rules
--timeout int concurrent task timeout in minutes (default 10)
--to string target ref to end diff at (e.g., 'feature-branch')
--tools string path to JSON tools config file (default: embedded)`)
--tools string path to JSON tools config file (default: embedded)
--upstream string upstream remote name or git URL to review the current branch against
--upstream-branch string branch on --upstream to compare against (default: its default branch)
--no-fetch with --upstream: skip network fetch, use local remote-tracking ref`)
}

// --- config subcommand ---
Expand Down
44 changes: 44 additions & 0 deletions cmd/opencodereview/flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package main

import (
"strings"
"testing"
)

func TestParseReviewFlagsUpstreamConflictsWithFrom(t *testing.T) {
_, err := parseReviewFlags([]string{"--upstream", "upstream", "--from", "main"})
if err == nil || !strings.Contains(err.Error(), "--upstream cannot be combined") {
t.Fatalf("expected conflict error, got: %v", err)
}
}

func TestParseReviewFlagsUpstreamConflictsWithCommit(t *testing.T) {
_, err := parseReviewFlags([]string{"--upstream", "upstream", "--commit", "abc123"})
if err == nil || !strings.Contains(err.Error(), "--upstream cannot be combined") {
t.Fatalf("expected conflict error, got: %v", err)
}
}

func TestParseReviewFlagsUpstreamBranchRequiresUpstream(t *testing.T) {
_, err := parseReviewFlags([]string{"--upstream-branch", "develop"})
if err == nil || !strings.Contains(err.Error(), "--upstream-branch requires --upstream") {
t.Fatalf("expected requires-upstream error, got: %v", err)
}
}

func TestParseReviewFlagsNoFetchRequiresUpstream(t *testing.T) {
_, err := parseReviewFlags([]string{"--no-fetch"})
if err == nil || !strings.Contains(err.Error(), "--no-fetch requires --upstream") {
t.Fatalf("expected requires-upstream error, got: %v", err)
}
}

func TestParseReviewFlagsUpstreamOK(t *testing.T) {
opts, err := parseReviewFlags([]string{"--upstream", "upstream", "--to", "feature"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.upstream != "upstream" || opts.to != "feature" {
t.Fatalf("unexpected opts: %+v", opts)
}
}
8 changes: 8 additions & 0 deletions cmd/opencodereview/review_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ func runReview(args []string) error {
if err != nil {
return fmt.Errorf("resolve repo: %w", err)
}
if opts.upstream != "" {
from, to, uerr := resolveUpstream(repoDir, opts)
if uerr != nil {
return uerr
}
opts.from = from
opts.to = to
}
if err := validateReviewRefs(repoDir, opts); err != nil {
return err
}
Expand Down
140 changes: 140 additions & 0 deletions cmd/opencodereview/upstream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package main

import (
"fmt"
"os"
"strings"
)

// isUpstreamURL reports whether spec looks like a git URL or local path rather
// than the name of a configured remote.
func isUpstreamURL(spec string) bool {
if strings.Contains(spec, "://") {
return true
}
if strings.HasPrefix(spec, "/") || strings.HasPrefix(spec, "./") ||
strings.HasPrefix(spec, "../") || strings.HasPrefix(spec, "~") {
return true
}
// scp-like syntax: user@host:path (a ':' that precedes any '/').
if i := strings.Index(spec, ":"); i > 0 {
slash := strings.Index(spec, "/")
if (slash == -1 || i < slash) && strings.Contains(spec[:i], "@") {
return true
}
}
return false
}

// upstreamRemoteExists reports whether name is a configured git remote in repoDir.
func upstreamRemoteExists(repoDir, name string) bool {
out, err := runGitCmd(repoDir, "remote")
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == name {
return true
}
}
return false
}

// parseSymrefDefaultBranch extracts the default branch name from the output of
// `git ls-remote --symref <target> HEAD`, e.g. a line "ref: refs/heads/main\tHEAD".
func parseSymrefDefaultBranch(lsRemoteOut string) (string, error) {
for _, line := range strings.Split(lsRemoteOut, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "ref:") {
continue
}
fields := strings.Fields(line) // ["ref:", "refs/heads/main", "HEAD"]
if len(fields) >= 2 && strings.HasPrefix(fields[1], "refs/heads/") {
return strings.TrimPrefix(fields[1], "refs/heads/"), nil
}
}
return "", fmt.Errorf("no default branch (HEAD symref) found in ls-remote output")
}

// discoverDefaultBranch queries target (a remote name or URL) for its default
// branch via `git ls-remote --symref`.
func discoverDefaultBranch(repoDir, target string) (string, error) {
out, err := runGitCmd(repoDir, "ls-remote", "--symref", "--end-of-options", target, "HEAD")
if err != nil {
return "", fmt.Errorf("git ls-remote %q failed: %s", target, strings.TrimSpace(string(out)))
}
branch, perr := parseSymrefDefaultBranch(string(out))
if perr != nil {
return "", fmt.Errorf("determine default branch for %q: %w", target, perr)
}
return branch, nil
}

// defaultTo returns to, or "HEAD" when to is empty.
func defaultTo(to string) string {
if to == "" {
return "HEAD"
}
return to
}

// resolveUpstream turns an --upstream spec (remote name or URL) into a concrete
// range to review: it fetches the upstream branch as needed and returns a usable
// `from` ref plus the `to` ref (defaulting to HEAD). The result feeds the normal
// ModeRange path: merge-base(from,to)..to.
func resolveUpstream(repoDir string, opts reviewOptions) (from, to string, err error) {
target := opts.upstream
isURL := isUpstreamURL(target)

if !isURL && !upstreamRemoteExists(repoDir, target) {
return "", "", fmt.Errorf("--upstream %q: no git remote named %q; pass a git URL or run \"git remote add %s <url>\"", target, target, target)
}
if isURL && opts.noFetch {
return "", "", fmt.Errorf("--no-fetch cannot be used with a URL upstream (%q); a URL must be fetched", target)
}

// Determine the upstream branch.
branch := opts.upstreamBranch
if branch == "" {
if opts.noFetch {
// Local-only discovery via the remote's HEAD symref, e.g.
// "refs/remotes/origin/HEAD" -> "refs/remotes/origin/main".
out, e := runGitCmd(repoDir, "symbolic-ref", "--end-of-options", "refs/remotes/"+target+"/HEAD")
ref := strings.TrimSpace(string(out))
prefix := "refs/remotes/" + target + "/"
if e != nil || !strings.HasPrefix(ref, prefix) {
return "", "", fmt.Errorf("--no-fetch: cannot determine default branch for %q locally; pass --upstream-branch", target)
}
branch = strings.TrimPrefix(ref, prefix)
} else {
branch, err = discoverDefaultBranch(repoDir, target)
if err != nil {
return "", "", err
}
}
}

// Fetch unless asked not to.
if !opts.noFetch {
fmt.Fprintf(os.Stderr, "[ocr] fetching %s from %s...\n", branch, target)
if out, e := runGitCmd(repoDir, "fetch", "--end-of-options", target, branch); e != nil {
// For a configured remote, fall back to a local tracking ref if present.
if !isURL {
if _, verr := runGitCmd(repoDir, "rev-parse", "--verify", "--end-of-options", target+"/"+branch+"^{commit}"); verr == nil {
fmt.Fprintf(os.Stderr, "[ocr] fetch failed (%s); using local %s/%s\n", strings.TrimSpace(string(out)), target, branch)
return target + "/" + branch, defaultTo(opts.to), nil
}
}
return "", "", fmt.Errorf("git fetch %s %s failed: %s", target, branch, strings.TrimSpace(string(out)))
}
}

if isURL {
out, e := runGitCmd(repoDir, "rev-parse", "--verify", "--end-of-options", "FETCH_HEAD^{commit}")
if e != nil {
return "", "", fmt.Errorf("resolve FETCH_HEAD after fetching %q: %s", target, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), defaultTo(opts.to), nil
}
return target + "/" + branch, defaultTo(opts.to), nil
}
Loading