Skip to content
Merged
63 changes: 33 additions & 30 deletions internal/daemon/broadcaster.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import (

// Event represents a review event that can be broadcast
type Event struct {
Type string `json:"type"`
TS time.Time `json:"ts"`
JobID int64 `json:"job_id"`
Repo string `json:"repo"`
RepoName string `json:"repo_name"`
SHA string `json:"sha"`
Agent string `json:"agent,omitempty"`
Verdict string `json:"verdict,omitempty"`
Findings string `json:"findings,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
TS time.Time `json:"ts"`
JobID int64 `json:"job_id"`
Repo string `json:"repo"`
RepoName string `json:"repo_name"`
SHA string `json:"sha"`
Agent string `json:"agent,omitempty"`
Verdict string `json:"verdict,omitempty"`
Findings string `json:"findings,omitempty"`
Error string `json:"error,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
}

// Subscriber represents a client subscribed to events
Expand Down Expand Up @@ -112,26 +113,28 @@ func (b *EventBroadcaster) SubscriberCount() int {
// MarshalJSON converts an Event to JSON for streaming
func (e Event) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
TS string `json:"ts"`
JobID int64 `json:"job_id"`
Repo string `json:"repo"`
RepoName string `json:"repo_name"`
SHA string `json:"sha"`
Agent string `json:"agent,omitempty"`
Verdict string `json:"verdict,omitempty"`
Findings string `json:"findings,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
TS string `json:"ts"`
JobID int64 `json:"job_id"`
Repo string `json:"repo"`
RepoName string `json:"repo_name"`
SHA string `json:"sha"`
Agent string `json:"agent,omitempty"`
Verdict string `json:"verdict,omitempty"`
Findings string `json:"findings,omitempty"`
Error string `json:"error,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
}{
Type: e.Type,
TS: e.TS.UTC().Format(time.RFC3339),
JobID: e.JobID,
Repo: e.Repo,
RepoName: e.RepoName,
SHA: e.SHA,
Agent: e.Agent,
Verdict: e.Verdict,
Findings: e.Findings,
Error: e.Error,
Type: e.Type,
TS: e.TS.UTC().Format(time.RFC3339),
JobID: e.JobID,
Repo: e.Repo,
RepoName: e.RepoName,
SHA: e.SHA,
Agent: e.Agent,
Verdict: e.Verdict,
Findings: e.Findings,
Error: e.Error,
WorktreePath: e.WorktreePath,
})
}
25 changes: 25 additions & 0 deletions internal/daemon/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,29 @@ func TestEvent_MarshalJSON(t *testing.T) {
// Explicitly check that 'error' is not present
_, hasError := decoded["error"]
assert.False(t, hasError, "expected 'error' field to be omitted")

// WorktreePath omitted when empty
_, hasWT := decoded["worktree_path"]
assert.False(t, hasWT, "expected 'worktree_path' to be omitted when empty")
}

func TestEvent_MarshalJSON_WorktreePath(t *testing.T) {
event := Event{
Type: "review.completed",
TS: time.Date(2026, 1, 11, 10, 0, 30, 0, time.UTC),
JobID: 42,
Repo: "/path/to/myrepo",
RepoName: "myrepo",
SHA: "abc123",
Agent: "claude-code",
WorktreePath: "/worktrees/feature-branch",
}

data, err := event.MarshalJSON()
require.NoError(t, err)

var decoded map[string]any
require.NoError(t, json.Unmarshal(data, &decoded))

assert.Equal(t, "/worktrees/feature-branch", decoded["worktree_path"])
}
19 changes: 14 additions & 5 deletions internal/daemon/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,20 @@ func (hr *HookRunner) handleEvent(event Event) {
return
}

// Collect hooks: copy global slice to avoid aliasing, then append repo-specific
hooks := append([]config.HookConfig{}, cfg.Hooks...)
// Resolve one effective repo path: prefer the worktree if it still
// exists and belongs to the same repository. Used for both config
// loading (.roborev.toml) and as the hook working directory.
effectiveRepo := event.Repo
if event.WorktreePath != "" && event.Repo != "" {
if gitpkg.ValidateWorktreeForRepo(event.WorktreePath, event.Repo) {
effectiveRepo = event.WorktreePath
}
}

if event.Repo != "" {
if repoCfg, err := config.LoadRepoConfig(event.Repo); err == nil && repoCfg != nil {
// Collect hooks: copy global slice to avoid aliasing, then append repo-specific.
hooks := append([]config.HookConfig{}, cfg.Hooks...)
if effectiveRepo != "" {
if repoCfg, err := config.LoadRepoConfig(effectiveRepo); err == nil && repoCfg != nil {
hooks = append(hooks, repoCfg.Hooks...)
}
}
Expand Down Expand Up @@ -170,7 +179,7 @@ func (hr *HookRunner) handleEvent(event Event) {
fired++
// Run async so hooks don't block workers
hr.wg.Add(1)
go hr.runHook(cmd, event.Repo)
go hr.runHook(cmd, effectiveRepo)
}

if fired > 0 {
Expand Down
128 changes: 71 additions & 57 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -691,9 +691,9 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
}
req.ReviewType = canonical[0]

// Get the working directory root for git commands (may be a worktree)
// This is needed to resolve refs like HEAD correctly in the worktree context
gitCwd, err := git.GetRepoRoot(req.RepoPath)
// Get the checkout root (via --show-toplevel) for git commands.
// For worktrees this is the worktree root; for the main repo it equals repoRoot.
checkoutRoot, err := git.GetRepoRoot(req.RepoPath)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("not a git repository: %v", err))
return
Expand All @@ -707,9 +707,17 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
return
}

// Detect worktree: if the worktree toplevel differs from the main
// repo root, the request originated from a worktree checkout.
// Clean both paths to avoid false positives from normalization differences.
var worktreePath string
if filepath.Clean(checkoutRoot) != filepath.Clean(repoRoot) {
worktreePath = filepath.Clean(checkoutRoot)
}

// Check if branch is excluded from reviews
currentBranch := git.GetCurrentBranch(gitCwd)
if currentBranch != "" && config.IsBranchExcluded(repoRoot, currentBranch) {
currentBranch := git.GetCurrentBranch(checkoutRoot)
if currentBranch != "" && config.IsBranchExcluded(checkoutRoot, currentBranch) {
// Silently skip excluded branches - return 200 OK with skipped flag
writeJSON(w, map[string]any{
"skipped": true,
Expand Down Expand Up @@ -834,42 +842,44 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
Label: gitRef, // Use git_ref as TUI label (run, analyze type, custom)
JobType: req.JobType,
Provider: req.Provider,
WorktreePath: worktreePath,
})
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("enqueue prompt job: %v", err))
return
}
} else if isDirty {
// Dirty review - use pre-captured diff
targetSHA, _ := git.ResolveSHA(gitCwd, "HEAD")
targetSHA, _ := git.ResolveSHA(checkoutRoot, "HEAD")
job, err = s.db.EnqueueJob(storage.EnqueueOpts{
RepoID: repo.ID,
GitRef: gitRef,
Branch: req.Branch,
SessionID: s.findReusableSessionID(repoRoot, repo.ID, req.Branch, agentName, req.ReviewType, targetSHA),
Agent: agentName,
Model: model,
Reasoning: reasoning,
ReviewType: req.ReviewType,
DiffContent: req.DiffContent,
Provider: req.Provider,
RepoID: repo.ID,
GitRef: gitRef,
Branch: req.Branch,
SessionID: s.findReusableSessionID(checkoutRoot, repo.ID, req.Branch, agentName, req.ReviewType, worktreePath, targetSHA),
Agent: agentName,
Model: model,
Reasoning: reasoning,
ReviewType: req.ReviewType,
DiffContent: req.DiffContent,
Provider: req.Provider,
WorktreePath: worktreePath,
})
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("enqueue dirty job: %v", err))
return
}
} else if isRange {
// For ranges, resolve both endpoints and create range job
// Use gitCwd to resolve refs correctly in worktree context
// Use checkoutRoot to resolve refs correctly in worktree context
parts := strings.SplitN(gitRef, "..", 2)
startSHA, err := git.ResolveSHA(gitCwd, parts[0])
startSHA, err := git.ResolveSHA(checkoutRoot, parts[0])
if err != nil {
// If the start ref is <sha>^ and resolution failed, the commit
// may be the root commit (no parent). Use the empty tree SHA so
// the range includes the root commit's changes.
if before, ok := strings.CutSuffix(parts[0], "^"); ok {
base := before
if _, resolveErr := git.ResolveSHA(gitCwd, base+"^{commit}"); resolveErr == nil {
if _, resolveErr := git.ResolveSHA(checkoutRoot, base+"^{commit}"); resolveErr == nil {
startSHA = git.EmptyTreeSHA
err = nil
}
Expand All @@ -879,7 +889,7 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
return
}
}
endSHA, err := git.ResolveSHA(gitCwd, parts[1])
endSHA, err := git.ResolveSHA(checkoutRoot, parts[1])
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid end commit: %v", err))
return
Expand All @@ -891,7 +901,7 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
// means we can't prove all are excluded.
fullRef := startSHA + ".." + endSHA
if rangeCommits, rcErr := git.GetRangeCommits(
gitCwd, fullRef,
checkoutRoot, fullRef,
); rcErr == nil && len(rangeCommits) > 0 {
messages := make([]string, 0, len(rangeCommits))
allRead := true
Expand All @@ -917,23 +927,24 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
}

job, err = s.db.EnqueueJob(storage.EnqueueOpts{
RepoID: repo.ID,
GitRef: fullRef,
Branch: req.Branch,
SessionID: s.findReusableSessionID(repoRoot, repo.ID, req.Branch, agentName, req.ReviewType, endSHA),
Agent: agentName,
Model: model,
Reasoning: reasoning,
ReviewType: req.ReviewType,
Provider: req.Provider,
RepoID: repo.ID,
GitRef: fullRef,
Branch: req.Branch,
SessionID: s.findReusableSessionID(checkoutRoot, repo.ID, req.Branch, agentName, req.ReviewType, worktreePath, endSHA),
Agent: agentName,
Model: model,
Reasoning: reasoning,
ReviewType: req.ReviewType,
Provider: req.Provider,
WorktreePath: worktreePath,
})
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("enqueue job: %v", err))
return
}
} else {
// Single commit - use gitCwd to resolve refs correctly in worktree context
sha, err := git.ResolveSHA(gitCwd, gitRef)
// Single commit - use checkoutRoot to resolve refs correctly in worktree context
sha, err := git.ResolveSHA(checkoutRoot, gitRef)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid commit: %v", err))
return
Expand Down Expand Up @@ -963,20 +974,21 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
return
}

patchID := git.GetPatchID(gitCwd, sha)
patchID := git.GetPatchID(checkoutRoot, sha)

job, err = s.db.EnqueueJob(storage.EnqueueOpts{
RepoID: repo.ID,
CommitID: commit.ID,
GitRef: sha,
Branch: req.Branch,
SessionID: s.findReusableSessionID(repoRoot, repo.ID, req.Branch, agentName, req.ReviewType, sha),
Agent: agentName,
Model: model,
Reasoning: reasoning,
ReviewType: req.ReviewType,
PatchID: patchID,
Provider: req.Provider,
RepoID: repo.ID,
CommitID: commit.ID,
GitRef: sha,
Branch: req.Branch,
SessionID: s.findReusableSessionID(checkoutRoot, repo.ID, req.Branch, agentName, req.ReviewType, worktreePath, sha),
Agent: agentName,
Model: model,
Reasoning: reasoning,
ReviewType: req.ReviewType,
PatchID: patchID,
Provider: req.Provider,
WorktreePath: worktreePath,
})
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("enqueue job: %v", err))
Expand Down Expand Up @@ -1008,7 +1020,7 @@ func (s *Server) handleEnqueue(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) findReusableSessionID(
repoPath string, repoID int64, branch, agentName, reviewType, targetSHA string,
repoPath string, repoID int64, branch, agentName, reviewType, worktreePath, targetSHA string,
) string {
cfg := s.configWatcher.Config()
if !config.ResolveReuseReviewSession(repoPath, cfg) || branch == "" || targetSHA == "" {
Expand All @@ -1020,6 +1032,7 @@ func (s *Server) findReusableSessionID(
branch,
agentName,
reviewType,
worktreePath,
config.ResolveReuseReviewSessionLookback(repoPath, cfg),
)
if err != nil {
Expand Down Expand Up @@ -2332,18 +2345,19 @@ func (s *Server) handleFixJob(w http.ResponseWriter, r *http.Request) {

// Enqueue the fix job
job, err := s.db.EnqueueJob(storage.EnqueueOpts{
RepoID: parentJob.RepoID,
CommitID: commitID,
GitRef: fixGitRef,
Branch: parentJob.Branch,
Agent: agentName,
Model: model,
Reasoning: reasoning,
Prompt: fixPrompt,
Agentic: true,
Label: fmt.Sprintf("fix #%d", req.ParentJobID),
JobType: storage.JobTypeFix,
ParentJobID: req.ParentJobID,
RepoID: parentJob.RepoID,
CommitID: commitID,
GitRef: fixGitRef,
Branch: parentJob.Branch,
Agent: agentName,
Model: model,
Reasoning: reasoning,
Prompt: fixPrompt,
Agentic: true,
Label: fmt.Sprintf("fix #%d", req.ParentJobID),
JobType: storage.JobTypeFix,
ParentJobID: req.ParentJobID,
WorktreePath: parentJob.WorktreePath,
})
if err != nil {
s.writeInternalError(w, fmt.Sprintf("enqueue fix job: %v", err))
Expand Down
Loading
Loading