Skip to content

Commit 4cc0b24

Browse files
committed
fix(tool): resolve file_read paths against git top-level in monorepos
ocr review from a monorepo subdirectory failed with "file not found" (alibaba#287): git reports diff and `git show HEAD:<path>` paths relative to the repo root, but RepoDir was scoped to the invocation subdirectory, producing a double prefix. resolveWorkingDir now anchors RepoDir at `git rev-parse --show-toplevel` on the review path (requireGit=true); scan keeps the CWD so its `git ls-files` walk stays scoped. The top-level lookup uses a stdout-only git helper so stderr notices can't pollute the path, and fails loudly if --show-toplevel errors or is empty (e.g. a bare repo) instead of silently reusing the subdirectory. Adds regression tests for the subdir hoist, the scan-path scoping, git-show resolution of root-relative paths, and the bare-repo failure.
1 parent 10f5875 commit 4cc0b24

4 files changed

Lines changed: 148 additions & 0 deletions

File tree

cmd/opencodereview/git.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ func runGitCmd(repoDir string, args ...string) ([]byte, error) {
1212
return cmd.CombinedOutput()
1313
}
1414

15+
// runGitCmdStdout is like runGitCmd but returns stdout only. Use it when the
16+
// output is consumed as data (e.g. a resolved path) so git's stderr warnings
17+
// (permissions, deprecations, config notices) can't pollute the result.
18+
func runGitCmdStdout(repoDir string, args ...string) ([]byte, error) {
19+
fullArgs := append([]string{"-C", repoDir}, args...)
20+
cmd := exec.Command("git", fullArgs...)
21+
return cmd.Output()
22+
}
23+
1524
func getCommitMessage(repoDir, commit string) (string, error) {
1625
out, err := runGitCmd(repoDir, "log", "-1", "--format=%B", "--end-of-options", commit)
1726
if err != nil {

cmd/opencodereview/shared.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"strings"
89
"time"
910

1011
"github.com/open-code-review/open-code-review/internal/agent"
@@ -100,6 +101,22 @@ func resolveWorkingDir(input string, requireGit bool) (string, bool, error) {
100101
if !isGit && requireGit {
101102
return "", false, fmt.Errorf("%s is not a git repository", absPath)
102103
}
104+
// #287: git reports diff and `git show HEAD:<path>` paths relative to the
105+
// repository root, not the current directory. When `ocr review` runs from a
106+
// subdirectory of a monorepo, anchor RepoDir at the git top-level so those
107+
// root-relative paths resolve for both disk reads and git-show reads.
108+
// requireGit is true only for the review path; scan (requireGit=false) keeps
109+
// the CWD so its `git ls-files` walk stays scoped to the subdirectory.
110+
if isGit && requireGit {
111+
// runGitCmdStdout captures stdout only so git stderr notices can't
112+
// pollute the resolved path. absPath is overridden only on success;
113+
// on any rev-parse failure it falls back to the caller-provided dir.
114+
if top, topErr := runGitCmdStdout(absPath, "rev-parse", "--show-toplevel"); topErr == nil {
115+
if t := strings.TrimSpace(string(top)); t != "" {
116+
absPath = t
117+
}
118+
}
119+
}
103120
return absPath, isGit, nil
104121
}
105122

cmd/opencodereview/shared_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"os"
5+
"os/exec"
56
"path/filepath"
67
"testing"
78

@@ -109,6 +110,71 @@ func TestResolveWorkingDir_NonExistent(t *testing.T) {
109110
}
110111
}
111112

113+
// TestResolveWorkingDir_MonorepoSubdir reproduces #287: running `ocr review`
114+
// from a subdirectory of a git repo must anchor RepoDir at the git top-level
115+
// (git reports diff / `git show HEAD:<path>` paths relative to the repo root),
116+
// while `ocr scan` (requireGit=false) must keep the subdirectory so its walk
117+
// stays scoped.
118+
func TestResolveWorkingDir_MonorepoSubdir(t *testing.T) {
119+
root := t.TempDir()
120+
git := func(args ...string) {
121+
t.Helper()
122+
cmd := exec.Command("git", args...)
123+
cmd.Dir = root
124+
if out, err := cmd.CombinedOutput(); err != nil {
125+
t.Fatalf("git %v: %v\n%s", args, err, out)
126+
}
127+
}
128+
git("init")
129+
git("config", "user.email", "t@t.co")
130+
git("config", "user.name", "t")
131+
132+
sub := filepath.Join(root, "subproject1", "src")
133+
if err := os.MkdirAll(sub, 0o755); err != nil {
134+
t.Fatal(err)
135+
}
136+
137+
// macOS /var -> /private/var symlink means t.TempDir() differs from the
138+
// canonicalized toplevel git returns; compare via EvalSymlinks.
139+
wantRoot, err := filepath.EvalSymlinks(root)
140+
if err != nil {
141+
t.Fatalf("EvalSymlinks(%q): %v", root, err)
142+
}
143+
144+
// review path: hoisted to the git top-level.
145+
got, isGit, err := resolveWorkingDir(sub, true)
146+
if err != nil {
147+
t.Fatalf("resolveWorkingDir(sub, true) error: %v", err)
148+
}
149+
if !isGit {
150+
t.Error("expected isGit=true for a git subdirectory")
151+
}
152+
gotResolved, err := filepath.EvalSymlinks(got)
153+
if err != nil {
154+
t.Fatalf("EvalSymlinks(%q): %v", got, err)
155+
}
156+
if gotResolved != wantRoot {
157+
t.Errorf("review RepoDir = %q, want git top-level %q", gotResolved, wantRoot)
158+
}
159+
160+
// scan path: keeps the subdirectory unchanged.
161+
gotScan, _, err := resolveWorkingDir(sub, false)
162+
if err != nil {
163+
t.Fatalf("resolveWorkingDir(sub, false) error: %v", err)
164+
}
165+
gotScanResolved, err := filepath.EvalSymlinks(gotScan)
166+
if err != nil {
167+
t.Fatalf("EvalSymlinks(%q): %v", gotScan, err)
168+
}
169+
wantSub, err := filepath.EvalSymlinks(sub)
170+
if err != nil {
171+
t.Fatalf("EvalSymlinks(%q): %v", sub, err)
172+
}
173+
if gotScanResolved != wantSub {
174+
t.Errorf("scan RepoDir = %q, want subdir %q (must stay scoped)", gotScanResolved, wantSub)
175+
}
176+
}
177+
112178
func TestResolveWorkingDir_GitRepo(t *testing.T) {
113179
dir := t.TempDir()
114180
gitDir := filepath.Join(dir, ".git")

internal/tool/filereader_read_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tool
33
import (
44
"context"
55
"os"
6+
"os/exec"
67
"path/filepath"
78
"strings"
89
"testing"
@@ -215,3 +216,58 @@ func TestFileReader_Read_SubdirectoryFile(t *testing.T) {
215216
t.Errorf("Read() = %q, want %q", got, "package main")
216217
}
217218
}
219+
220+
// TestFileReader_Read_CommitMode_MonorepoSubdirPath reproduces #287 at the
221+
// git-show layer: in a monorepo, git reports paths relative to the repo root
222+
// (e.g. "subproject1/src/models/request_meta.py"). With RepoDir anchored at the
223+
// git top-level (the fix), `git show HEAD:<root-relative-path>` must resolve —
224+
// this is the exact command that failed in the issue.
225+
func TestFileReader_Read_CommitMode_MonorepoSubdirPath(t *testing.T) {
226+
dir := t.TempDir()
227+
git := func(args ...string) {
228+
t.Helper()
229+
cmd := exec.Command("git", args...)
230+
cmd.Dir = dir
231+
if out, err := cmd.CombinedOutput(); err != nil {
232+
t.Fatalf("git %v: %v\n%s", args, err, out)
233+
}
234+
}
235+
git("init")
236+
git("config", "user.email", "t@t.co")
237+
git("config", "user.name", "t")
238+
239+
rel := filepath.Join("subproject1", "src", "models", "request_meta.py")
240+
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(rel)), 0o755); err != nil {
241+
t.Fatal(err)
242+
}
243+
content := "class RequestMeta:\n id = 1\n"
244+
if err := os.WriteFile(filepath.Join(dir, rel), []byte(content), 0o644); err != nil {
245+
t.Fatal(err)
246+
}
247+
git("add", ".")
248+
git("commit", "-m", "init")
249+
commit := getHeadCommit(t, dir)
250+
251+
// Use the git-style forward-slash path the diff/LLM would supply.
252+
gitPath := "subproject1/src/models/request_meta.py"
253+
254+
// git-show (commit mode): the exact path from the issue error.
255+
frShow := &FileReader{RepoDir: dir, Mode: ModeCommit, Ref: commit}
256+
got, err := frShow.Read(context.Background(), gitPath)
257+
if err != nil {
258+
t.Fatalf("commit-mode Read(%q) error: %v", gitPath, err)
259+
}
260+
if got != content {
261+
t.Errorf("commit-mode Read = %q, want %q", got, content)
262+
}
263+
264+
// disk (workspace mode): same root-relative path resolves too.
265+
frDisk := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
266+
gotDisk, err := frDisk.Read(context.Background(), gitPath)
267+
if err != nil {
268+
t.Fatalf("workspace-mode Read(%q) error: %v", gitPath, err)
269+
}
270+
if gotDisk != content {
271+
t.Errorf("workspace-mode Read = %q, want %q", gotDisk, content)
272+
}
273+
}

0 commit comments

Comments
 (0)