open-code-review/cmd/opencodereview/shared_test.go
chethanuk c973e581ec
Some checks are pending
CI / test (push) Waiting to run
fix(tool): resolve file_read paths against git top-level in monorepos (#309)
* fix(tool): resolve file_read paths against git top-level in monorepos

ocr review from a monorepo subdirectory failed with "file not found" (#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.

* docs(rules): document repo-root rule.json resolution in monorepos

Since #287 anchored RepoDir at the git top-level, ocr review from a
monorepo subdirectory loads the repo-root .opencodereview/rule.json
rather than a subdir-local one. Call out this user-visible behavior at
loadProjectRule so the scope change isn't a surprise (review feedback).
2026-07-07 20:06:28 +08:00

211 lines
5.7 KiB
Go

package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/open-code-review/open-code-review/internal/config/rules"
)
func TestApplyCLIExcludes_Empty(t *testing.T) {
cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
applyCLIExcludes(cc, nil)
if len(cc.FileFilter.Exclude) != 1 {
t.Errorf("expected 1 exclude, got %d", len(cc.FileFilter.Exclude))
}
}
func TestApplyCLIExcludes_AppendsPatterns(t *testing.T) {
cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
applyCLIExcludes(cc, []string{"b", "c"})
if len(cc.FileFilter.Exclude) != 3 {
t.Errorf("expected 3 excludes, got %d", len(cc.FileFilter.Exclude))
}
}
func TestApplyCLIExcludes_NilFileFilter(t *testing.T) {
cc := &commonContext{}
applyCLIExcludes(cc, []string{"x"})
if cc.FileFilter == nil {
t.Fatal("expected FileFilter to be created")
}
if len(cc.FileFilter.Exclude) != 1 || cc.FileFilter.Exclude[0] != "x" {
t.Errorf("expected [x], got %v", cc.FileFilter.Exclude)
}
}
func TestNewQuietHandle_NoOp(t *testing.T) {
h := newQuietHandle("text", "developer")
if h.fn != nil {
t.Error("expected no-op handle for text/developer")
}
h.Restore()
}
func TestNewQuietHandle_JSON(t *testing.T) {
h := newQuietHandle("json", "developer")
if h.fn == nil {
t.Error("expected fn to be set for json format")
}
h.Restore()
if h.fn != nil {
t.Error("expected fn to be nil after Restore")
}
}
func TestNewQuietHandle_Agent(t *testing.T) {
h := newQuietHandle("text", "agent")
if h.fn == nil {
t.Error("expected fn to be set for agent audience")
}
h.Restore()
}
func TestQuietHandle_NilReceiver(t *testing.T) {
var h *quietHandle
h.Restore()
}
func TestQuietHandle_IdempotentRestore(t *testing.T) {
h := newQuietHandle("json", "developer")
h.Restore()
h.Restore()
if h.fn != nil {
t.Error("expected nil after double restore")
}
}
func TestResolveWorkingDir_CurrentDir(t *testing.T) {
dir := t.TempDir()
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
absPath, isGit, err := resolveWorkingDir("", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if absPath == "" {
t.Error("expected non-empty absPath")
}
if isGit {
t.Error("temp dir should not be a git repo")
}
}
func TestResolveWorkingDir_RequireGitFails(t *testing.T) {
dir := t.TempDir()
_, _, err := resolveWorkingDir(dir, true)
if err == nil {
t.Fatal("expected error for non-git dir with requireGit=true")
}
}
func TestResolveWorkingDir_NonExistent(t *testing.T) {
_, _, err := resolveWorkingDir(filepath.Join(t.TempDir(), "no-such-dir"), false)
if err == nil {
t.Fatal("expected error for non-existent path")
}
}
// TestResolveWorkingDir_MonorepoSubdir reproduces #287: running `ocr review`
// from a subdirectory of a git repo must anchor RepoDir at the git top-level
// (git reports diff / `git show HEAD:<path>` paths relative to the repo root),
// while `ocr scan` (requireGit=false) must keep the subdirectory so its walk
// stays scoped.
func TestResolveWorkingDir_MonorepoSubdir(t *testing.T) {
root := t.TempDir()
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
git("init")
git("config", "user.email", "t@t.co")
git("config", "user.name", "t")
sub := filepath.Join(root, "subproject1", "src")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
// macOS /var -> /private/var symlink means t.TempDir() differs from the
// canonicalized toplevel git returns; compare via EvalSymlinks.
wantRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", root, err)
}
// review path: hoisted to the git top-level.
got, isGit, err := resolveWorkingDir(sub, true)
if err != nil {
t.Fatalf("resolveWorkingDir(sub, true) error: %v", err)
}
if !isGit {
t.Error("expected isGit=true for a git subdirectory")
}
gotResolved, err := filepath.EvalSymlinks(got)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", got, err)
}
if gotResolved != wantRoot {
t.Errorf("review RepoDir = %q, want git top-level %q", gotResolved, wantRoot)
}
// scan path: keeps the subdirectory unchanged.
gotScan, _, err := resolveWorkingDir(sub, false)
if err != nil {
t.Fatalf("resolveWorkingDir(sub, false) error: %v", err)
}
gotScanResolved, err := filepath.EvalSymlinks(gotScan)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", gotScan, err)
}
wantSub, err := filepath.EvalSymlinks(sub)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", sub, err)
}
if gotScanResolved != wantSub {
t.Errorf("scan RepoDir = %q, want subdir %q (must stay scoped)", gotScanResolved, wantSub)
}
}
// TestResolveWorkingDir_BareRepoFailsLoudly guards the #287 fix: a bare repo has
// no work tree, so `git rev-parse --git-dir` succeeds (isGit=true) but
// `--show-toplevel` fails. The review path (requireGit=true) must return an
// error rather than silently reusing the input dir, which would reproduce the
// original root-relative-path bug.
func TestResolveWorkingDir_BareRepoFailsLoudly(t *testing.T) {
bare := t.TempDir()
cmd := exec.Command("git", "init", "--bare", bare)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init --bare: %v\n%s", err, out)
}
_, _, err := resolveWorkingDir(bare, true)
if err == nil {
t.Fatal("expected error for a bare repo (no work tree), got nil")
}
}
func TestResolveWorkingDir_GitRepo(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, ".git")
if err := os.Mkdir(gitDir, 0o755); err != nil {
t.Fatal(err)
}
absPath, isGit, err := resolveWorkingDir(dir, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if absPath == "" {
t.Error("expected non-empty absPath")
}
_ = isGit
}