Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions cmd/roborev/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
Expand Down Expand Up @@ -75,12 +76,22 @@ Examples:
branch = git.GetCurrentBranch(localRepoPath)
}

// Workspace mode: not in a git repo and no --repo specified
var repoPrefix string
if repoPath == "" && localRepoPath == "" {
if abs, err := filepath.Abs("."); err == nil {
repoPrefix = filepath.ToSlash(abs)
}
}

// Build query URL
params := url.Values{}
if repoPath != "" {
if repoPrefix != "" {
params.Set("repo_prefix", repoPrefix)
} else if repoPath != "" {
params.Set("repo", repoPath)
}
if branch != "" {
if branch != "" && (repoPrefix == "" || cmd.Flags().Changed("branch")) {
params.Set("branch", branch)
params.Set("branch_include_empty", "true")
}
Expand Down
28 changes: 28 additions & 0 deletions cmd/roborev/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,34 @@ func TestListCommand(t *testing.T) {
}
},
},
{
name: "workspace mode suppresses auto-detected branch",
repoSetup: func(t *testing.T) repoSetupResult {
// Create a non-git parent dir (workspace mode)
parent := t.TempDir()
return repoSetupResult{
workingDir: parent,
repo: nil,
extraArgs: nil,
}
},
handler: jobsHandler([]storage.ReviewJob{}, false),
wantQuery: []string{"repo_prefix="},
notWantQuery: []string{"branch="},
},
{
name: "workspace mode honors explicit --branch",
repoSetup: func(t *testing.T) repoSetupResult {
parent := t.TempDir()
return repoSetupResult{
workingDir: parent,
repo: nil,
extraArgs: []string{"--branch", "main"},
}
},
handler: jobsHandler([]storage.ReviewJob{}, false),
wantQuery: []string{"repo_prefix=", "branch=main", "branch_include_empty=true"},
},
}

for _, tc := range tests {
Expand Down
33 changes: 33 additions & 0 deletions cmd/roborev/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/roborev-dev/roborev/internal/agent"
"github.com/roborev-dev/roborev/internal/config"
Expand Down Expand Up @@ -80,6 +83,17 @@ Examples:
if quiet {
return nil // Not a repo - silent exit for hooks
}
// Scan for child git repos to give a helpful hint
if children := findChildGitRepos(repoPath); len(children) > 0 {
absDir, _ := filepath.Abs(repoPath)
var b strings.Builder
b.WriteString("not in a git repository; use --repo to specify one:")
for _, name := range children {
b.WriteString("\n roborev review --repo ")
b.WriteString(filepath.Join(absDir, name))
}
return fmt.Errorf("%s", b.String())
}
return fmt.Errorf("not a git repository: %w", err)
}

Expand Down Expand Up @@ -411,3 +425,22 @@ func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName
}
return nil
}

// findChildGitRepos returns the names of immediate child directories that are git repos.
func findChildGitRepos(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var repos []string
for _, e := range entries {
if !e.IsDir() || e.Name()[0] == '.' {
continue
}
gitDir := filepath.Join(dir, e.Name(), ".git")
if _, err := os.Stat(gitDir); err == nil {
repos = append(repos, e.Name())
}
}
return repos
}
75 changes: 75 additions & 0 deletions cmd/roborev/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,78 @@ func TestReviewInvalidArgsNoSideEffects(t *testing.T) {
}
}
}

func TestFindChildGitRepos(t *testing.T) {
parent := t.TempDir()

// Create a regular git repo (directory .git)
regularRepo := filepath.Join(parent, "regular")
os.Mkdir(regularRepo, 0755)
os.Mkdir(filepath.Join(regularRepo, ".git"), 0755)

// Create a worktree-style repo (.git is a file)
worktreeRepo := filepath.Join(parent, "worktree")
os.Mkdir(worktreeRepo, 0755)
os.WriteFile(
filepath.Join(worktreeRepo, ".git"),
[]byte("gitdir: /some/main/.git/worktrees/wt"),
0644,
)

// Create a non-repo directory (no .git at all)
plainDir := filepath.Join(parent, "plain")
os.Mkdir(plainDir, 0755)

// Create a hidden directory (should be skipped)
hiddenDir := filepath.Join(parent, ".hidden")
os.Mkdir(hiddenDir, 0755)
os.Mkdir(filepath.Join(hiddenDir, ".git"), 0755)

repos := findChildGitRepos(parent)

if len(repos) != 2 {
t.Fatalf("Expected 2 repos, got %d: %v", len(repos), repos)
}

found := make(map[string]bool)
for _, r := range repos {
found[r] = true
}
if !found["regular"] {
t.Error("Expected 'regular' in results")
}
if !found["worktree"] {
t.Error("Expected 'worktree' in results (has .git file)")
}
if found["plain"] {
t.Error("'plain' should not be in results")
}
if found[".hidden"] {
t.Error("'.hidden' should not be in results")
}
}

func TestFindChildGitReposHintPaths(t *testing.T) {
parent := t.TempDir()

// Create a regular git repo
repoDir := filepath.Join(parent, "my-repo")
os.Mkdir(repoDir, 0755)
os.Mkdir(filepath.Join(repoDir, ".git"), 0755)

// Run the review command against the parent dir (not a git repo)
// to verify the hint message contains full paths
_, _, err := executeReviewCmd("--repo", parent)
if err == nil {
t.Fatal("Expected error for non-git directory")
}

errMsg := err.Error()
expectedPath := filepath.Join(parent, "my-repo")
if !strings.Contains(errMsg, expectedPath) {
t.Errorf(
"Hint should contain full path %q, got: %s",
expectedPath, errMsg,
)
}
}
10 changes: 6 additions & 4 deletions cmd/roborev/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ Examples:
}
// Default to HEAD
sha := "HEAD"
if root, err := git.GetRepoRoot("."); err == nil {
if resolved, err := git.ResolveSHA(root, sha); err == nil {
sha = resolved
}
root, rootErr := git.GetRepoRoot(".")
if rootErr != nil {
return fmt.Errorf("not in a git repository; use a job ID instead (e.g., roborev show 42)")
}
if resolved, err := git.ResolveSHA(root, sha); err == nil {
sha = resolved
}
queryURL = addr + "/api/review?sha=" + sha
displayRef = git.ShortSHA(sha)
Expand Down
33 changes: 33 additions & 0 deletions cmd/roborev/show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,39 @@ func TestShowOutputFormat(t *testing.T) {
}
}

func TestShowOutsideGitRepo(t *testing.T) {
t.Run("no args outside git repo returns guidance error", func(t *testing.T) {
nonGitDir := t.TempDir()
chdir(t, nonGitDir)

mockReviewDaemon(t, storage.Review{})

cmd := showCmd()
cmd.SetArgs([]string{})
err := cmd.Execute()
assertErrorContains(t, err, "not in a git repository")
assertErrorContains(t, err, "job ID")
})

t.Run("job ID outside git repo still works", func(t *testing.T) {
nonGitDir := t.TempDir()
chdir(t, nonGitDir)

getQuery := mockReviewDaemon(t, storage.Review{
ID: 1, JobID: 42, Output: "LGTM", Agent: "test",
})

output := runShowCmd(t, "--job", "42")
q := getQuery()
if !strings.Contains(q, "job_id=42") {
t.Errorf("expected job_id=42 in query, got: %s", q)
}
if !strings.Contains(output, "LGTM") {
t.Errorf("expected review output in result, got: %s", output)
}
})
}

func TestShowJSONOutput(t *testing.T) {
repo := newTestGitRepo(t)
repo.CommitFile("file.txt", "content", "initial commit")
Expand Down
16 changes: 8 additions & 8 deletions internal/daemon/ci_poller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func newCIPollerHarness(t *testing.T, identity string) *ciPollerHarness {
cfg := config.DefaultConfig()
cfg.CI.Enabled = true
p := NewCIPoller(db, NewStaticConfig(cfg), nil)
return &ciPollerHarness{DB: db, RepoPath: repoPath, Repo: repo, Cfg: cfg, Poller: p}
return &ciPollerHarness{DB: db, RepoPath: repo.RootPath, Repo: repo, Cfg: cfg, Poller: p}
}

// stubProcessPRGit wires up git stubs on the poller so processPR doesn't
Expand Down Expand Up @@ -782,8 +782,8 @@ func TestCIPollerFindLocalRepo_SkipsPlaceholders(t *testing.T) {
if found.ID != repo.ID {
t.Errorf("found repo id %d, want %d", found.ID, repo.ID)
}
if found.RootPath != repoPath {
t.Errorf("found repo root_path %q, want %q", found.RootPath, repoPath)
if found.RootPath != repo.RootPath {
t.Errorf("found repo root_path %q, want %q", found.RootPath, repo.RootPath)
}
}

Expand Down Expand Up @@ -1265,7 +1265,7 @@ func TestCIPollerFindOrCloneRepo_AutoClones(t *testing.T) {
t.Fatal("expected non-nil repo")
}

wantPath := filepath.Join(dataDir, "clones", "acme", "newrepo")
wantPath := filepath.ToSlash(filepath.Join(dataDir, "clones", "acme", "newrepo"))
if repo.RootPath != wantPath {
t.Errorf(
"repo.RootPath=%q, want %q", repo.RootPath, wantPath,
Expand Down Expand Up @@ -1331,9 +1331,9 @@ func TestCIPollerFindOrCloneRepo_ReusesExistingDir(t *testing.T) {
if err != nil {
t.Fatalf("findOrCloneRepo: %v", err)
}
if repo.RootPath != clonePath {
if repo.RootPath != filepath.ToSlash(clonePath) {
t.Errorf(
"repo.RootPath=%q, want %q", repo.RootPath, clonePath,
"repo.RootPath=%q, want %q", repo.RootPath, filepath.ToSlash(clonePath),
)
}
}
Expand Down Expand Up @@ -1436,8 +1436,8 @@ func TestCIPollerFindOrCloneRepo_InvalidExistingDir(t *testing.T) {
t.Fatal("expected non-nil repo")
}
if tt.name == "empty dir is re-cloned" {
if repo.RootPath != clonePath {
t.Errorf("RootPath=%q, want %q", repo.RootPath, clonePath)
if repo.RootPath != filepath.ToSlash(clonePath) {
t.Errorf("RootPath=%q, want %q", repo.RootPath, filepath.ToSlash(clonePath))
}
}
})
Expand Down
34 changes: 25 additions & 9 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -814,7 +815,14 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {

status := r.URL.Query().Get("status")
repo := r.URL.Query().Get("repo")
if repo != "" {
repo = filepath.ToSlash(filepath.Clean(repo))
}
gitRef := r.URL.Query().Get("git_ref")
repoPrefix := r.URL.Query().Get("repo_prefix")
if repoPrefix != "" {
repoPrefix = filepath.ToSlash(filepath.Clean(repoPrefix))
}

// Parse limit from query, default to 50, 0 means no limit
// Clamp to valid range: 0 (unlimited) or 1-10000
Expand Down Expand Up @@ -870,6 +878,9 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
if exJobType := r.URL.Query().Get("exclude_job_type"); exJobType != "" {
listOpts = append(listOpts, storage.WithExcludeJobType(exJobType))
}
if repoPrefix != "" && repo == "" {
listOpts = append(listOpts, storage.WithRepoPrefix(repoPrefix))
}

jobs, err := s.db.ListJobs(status, repo, fetchLimit, offset, listOpts...)
if err != nil {
Expand All @@ -893,6 +904,9 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
statsOpts = append(statsOpts, storage.WithBranch(branch))
}
}
if repoPrefix != "" && repo == "" {
statsOpts = append(statsOpts, storage.WithRepoPrefix(repoPrefix))
}
stats, statsErr := s.db.CountJobStats(repo, statsOpts...)
if statsErr != nil {
log.Printf("Warning: failed to count job stats: %v", statsErr)
Expand All @@ -911,18 +925,20 @@ func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) {
return
}

// Optional branch filter
branch := r.URL.Query().Get("branch")
prefix := r.URL.Query().Get("prefix")
if prefix != "" {
prefix = filepath.ToSlash(filepath.Clean(prefix))
}

var repos []storage.RepoWithCount
var totalCount int
var err error

var repoOpts []storage.ListReposOption
if prefix != "" {
repoOpts = append(repoOpts, storage.WithRepoPathPrefix(prefix))
}
if branch != "" {
repos, totalCount, err = s.db.ListReposWithReviewCountsByBranch(branch)
} else {
repos, totalCount, err = s.db.ListReposWithReviewCounts()
repoOpts = append(repoOpts, storage.WithRepoBranch(branch))
}
repos, totalCount, err := s.db.ListReposWithReviewCounts(repoOpts...)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("list repos: %v", err))
return
Expand Down Expand Up @@ -984,7 +1000,7 @@ func (s *Server) handleListBranches(w http.ResponseWriter, r *http.Request) {
var repoPaths []string
for _, p := range r.URL.Query()["repo"] {
if p != "" {
repoPaths = append(repoPaths, p)
repoPaths = append(repoPaths, filepath.ToSlash(filepath.Clean(p)))
}
}

Expand Down
Loading
Loading