Skip to content
Closed
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
29 changes: 21 additions & 8 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 @@ -815,6 +816,10 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
repo := r.URL.Query().Get("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 +875,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 +901,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 +922,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
Loading
Loading