Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8c1cd0d
feat: add fix job type with parent_job_id and patch columns (#279)
mariusvniekerk Feb 17, 2026
5749b18
refactor: extract worktree helpers to internal/worktree package (#279)
mariusvniekerk Feb 17, 2026
3e3306a
feat: add fix job processing with worktree isolation in worker pool (…
mariusvniekerk Feb 17, 2026
928930c
feat: add /api/job/fix and /api/job/patch endpoints (#279)
mariusvniekerk Feb 17, 2026
ec7ec8d
feat: add TUI Tasks view and fix prompt for background fix jobs (#279)
mariusvniekerk Feb 17, 2026
c6f1e4f
feat: add automatic rebase flow for stale fix patches (#279)
mariusvniekerk Feb 17, 2026
1231851
feat: update help text and hint bars for fix/tasks features (#279)
mariusvniekerk Feb 17, 2026
457874f
docs: update plan and CLAUDE.md to match implementation (#279)
mariusvniekerk Feb 17, 2026
7424bb5
refactor: address API design review findings (#279)
mariusvniekerk Feb 17, 2026
3c88dc8
test: add tests for worktree CapturePatch, ApplyPatch, CheckPatch (#279)
mariusvniekerk Feb 17, 2026
1ab3ca0
fix: address roborev-ci review findings (#279)
mariusvniekerk Feb 17, 2026
511e41a
fix: use writeJSONWithStatus for non-200 responses after upstream API…
mariusvniekerk Feb 17, 2026
4fef22c
fix: resolve golangci-lint warnings (errcheck, modernize, fmtappendf)
mariusvniekerk Feb 17, 2026
623041f
fix: use fmt.Fprintf instead of WriteString(fmt.Sprintf) (staticcheck…
mariusvniekerk Feb 17, 2026
96a2412
fix: make fix job patch persistence atomic with status transition
mariusvniekerk Feb 17, 2026
a371623
fix: address PR review findings (patch conflicts, leak, rebase sizing)
mariusvniekerk Feb 18, 2026
3205db1
fix: filter fix jobs from main queue view, show only in Tasks
mariusvniekerk Feb 18, 2026
c96e4cc
fix: improve agent failure reporting with stream errors and partial o…
mariusvniekerk Feb 18, 2026
606ae0b
fix: auto-refresh Tasks view on tick when viewing or jobs are active
mariusvniekerk Feb 18, 2026
2ee5b27
fix: add enter/t handlers for finished fix jobs in Tasks view
mariusvniekerk Feb 18, 2026
ed9108d
fix: apply roborev fix for e4b4130 (job #141)
mariusvniekerk Feb 18, 2026
52266fc
feat: patch viewer, apply+commit, ESC navigation, and tasks UI improv…
mariusvniekerk Feb 18, 2026
af81286
feat: tasks view dynamic columns with headers, patch viewer, ESC nav fix
mariusvniekerk Feb 18, 2026
534a520
fix: give subject column more space in tasks view
mariusvniekerk Feb 18, 2026
67c426f
feat: add applied status for fix jobs, hide verdict for fix reviews
mariusvniekerk Feb 18, 2026
1aab636
feat: add rebased terminal status, running task viewing, tail from pr…
mariusvniekerk Feb 18, 2026
f939352
refactor: add HasViewableOutput helper to simplify status checks
mariusvniekerk Feb 18, 2026
8bb6154
fix: apply roborev fix for b83dcf5 (job #144)
mariusvniekerk Feb 18, 2026
8bf7fac
fix: stop silently discarding errors in apply, rebase, and addressed …
mariusvniekerk Feb 18, 2026
5977bc0
fix: add applied/rebased to CHECK constraint with DB migration
mariusvniekerk Feb 18, 2026
9ffa702
refactor: use sourcegraph/go-diff for patch file extraction
mariusvniekerk Feb 18, 2026
2aaacfd
fix: handle renames in patchFiles by adding both old and new paths
mariusvniekerk Feb 18, 2026
d85923e
fix: apply roborev fix for 81eb103 (job #149)
mariusvniekerk Feb 18, 2026
f8a06da
fix: add missing SQL placeholder in EnqueueJob after rebase merge
mariusvniekerk Feb 18, 2026
47989c2
fix: enforce job_type=fix for applied/rebased, check dirty files befo…
mariusvniekerk Feb 19, 2026
3ad3bf9
fix: resolve post-rebase compilation errors from upstream test refact…
mariusvniekerk Feb 19, 2026
850af81
fix: use strings.SplitSeq for efficient iteration (modernize lint)
mariusvniekerk Feb 19, 2026
f22609b
fix: update nix vendorHash for sourcegraph/go-diff dependency
mariusvniekerk Feb 19, 2026
e171471
fix: remove duplicate test functions and update integration tests to …
mariusvniekerk Feb 19, 2026
58d0ffd
fix: remove unused installGitHook helper (golangci-lint unused)
mariusvniekerk Feb 19, 2026
8ed2136
fix: harden TUI fix-job apply/rebase flow
wesm Feb 19, 2026
ea5a04a
fix: patchFiles prefix bug, rebased flash warning, job_type filter
wesm Feb 19, 2026
da9c721
test: add regression tests for patchFiles, job_type filter, rebase wa…
wesm Feb 19, 2026
22b403c
fix: exclude fix jobs from queue server-side, suppress rebased warning
wesm Feb 19, 2026
f45d77c
fix: validate stale job in handleFixJob, harden tests
wesm Feb 19, 2026
3e9221a
fix: harden patch apply and stale job rebase validation
wesm Feb 19, 2026
b7da9c3
fix: skip TestDaemonSignalCleanup on Windows (file locking)
wesm Feb 19, 2026
819bdb6
fix: prevent fix-of-fix chains and fix WorkerPool data race
wesm Feb 19, 2026
780d6c8
test: cover non-terminal stale status and dirtyPatchFiles error path
wesm Feb 19, 2026
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ CLI (roborev) → HTTP API → Daemon (roborev daemon run) → Worker Pool → A
| `internal/storage/` | SQLite operations |
| `internal/agent/` | Agent interface + implementations |
| `internal/config/config.go` | Config loading, agent resolution |
| `internal/worktree/` | Git worktree helpers (create, patch capture/apply) |

## Conventions

Expand Down Expand Up @@ -93,7 +94,7 @@ CLI reads this to find the daemon. If port 7373 is busy, daemon auto-increments.

## Design Constraints

- **Daemon tasks must not modify the git working tree.** Background jobs (reviews, CI polling, synthesis) are read-only with respect to the user's repo checkout. They read source files and write results to the database only. CLI commands like `roborev fix` run synchronously in the foreground and may modify files, but nothing enqueued to the worker pool should touch the working tree. If we need background tasks that produce file changes in the future, they should operate in isolated git worktrees — that is a separate initiative.
- **Daemon tasks must not modify the git working tree.** Background jobs (reviews, CI polling, synthesis) are read-only with respect to the user's repo checkout. They read source files and write results to the database only. CLI commands like `roborev fix` run synchronously in the foreground and may modify files. Background `fix` jobs run agents in isolated git worktrees (via `internal/worktree`) and store resulting patches in the database — patches are only applied to the working tree when the user explicitly confirms in the TUI.

## Style Preferences

Expand Down
4 changes: 4 additions & 0 deletions cmd/roborev/daemon_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ func TestDaemonShutdownBySignal(t *testing.T) {
}

func TestDaemonSignalCleanup(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping daemon signal test on Windows due to file locking differences")
}

// Verify that signal.Stop is called when shutdown
// is triggered by a signal.
var cleanupCalled bool
Expand Down
199 changes: 20 additions & 179 deletions cmd/roborev/refine.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
package main

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"

Expand All @@ -23,6 +17,7 @@ import (
"github.com/roborev-dev/roborev/internal/git"
"github.com/roborev-dev/roborev/internal/prompt"
"github.com/roborev-dev/roborev/internal/storage"
"github.com/roborev-dev/roborev/internal/worktree"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -513,13 +508,14 @@ func runRefine(ctx RunContext, opts refineOptions) error {
branchBefore := git.GetCurrentBranch(repoPath)

// Create temp worktree to isolate agent from user's working tree
worktreePath, cleanupWorktree, err := createTempWorktree(repoPath)
wt, err := worktree.Create(repoPath)
if err != nil {
return fmt.Errorf("create worktree: %w", err)
}
worktreePath := wt.Dir
// NOTE: not using defer here because we're inside a loop;
// defer wouldn't run until runRefine returns, leaking worktrees.
// Instead, cleanupWorktree() is called explicitly before every exit point.
// Instead, wt.Close() is called explicitly before every exit point.

// Determine output writer
var agentOutput io.Writer
Expand Down Expand Up @@ -554,31 +550,31 @@ func runRefine(ctx RunContext, opts refineOptions) error {

// Safety checks on main repo (before applying any changes)
if wasCleanBefore && !git.IsWorkingTreeClean(repoPath) {
cleanupWorktree()
wt.Close()
return fmt.Errorf("working tree changed during refine - aborting to prevent data loss")
}
headAfterAgent, resolveErr := git.ResolveSHA(repoPath, "HEAD")
if resolveErr != nil {
cleanupWorktree()
wt.Close()
return fmt.Errorf("cannot determine HEAD after agent run: %w", resolveErr)
}
branchAfterAgent := git.GetCurrentBranch(repoPath)
if headAfterAgent != headBefore || branchAfterAgent != branchBefore {
cleanupWorktree()
wt.Close()
return fmt.Errorf("HEAD changed during refine (was %s on %s, now %s on %s) - aborting to prevent applying patch to wrong commit",
shortSHA(headBefore), branchBefore, shortSHA(headAfterAgent), branchAfterAgent)
}

if agentErr != nil {
cleanupWorktree()
wt.Close()
fmt.Printf("Agent error: %v\n", agentErr)
fmt.Println("Will retry in next iteration")
continue
}

// Check if agent made changes in worktree
if git.IsWorkingTreeClean(worktreePath) {
cleanupWorktree()
wt.Close()
fmt.Println("Agent made no changes - skipping this review")
if err := client.AddComment(currentFailedReview.JobID, "roborev-refine", "Agent could not determine how to address findings"); err != nil {
fmt.Printf("Warning: failed to add comment to job %d: %v\n", currentFailedReview.JobID, err)
Expand All @@ -588,12 +584,17 @@ func runRefine(ctx RunContext, opts refineOptions) error {
continue
}

// Apply worktree changes to main repo and commit
if err := applyWorktreeChanges(repoPath, worktreePath); err != nil {
cleanupWorktree()
return fmt.Errorf("apply worktree changes: %w", err)
// Capture patch from worktree and apply to main repo
patch, err := wt.CapturePatch()
if err != nil {
wt.Close()
return fmt.Errorf("capture worktree patch: %w", err)
}
if err := worktree.ApplyPatch(repoPath, patch); err != nil {
wt.Close()
return fmt.Errorf("apply worktree patch: %w", err)
}
cleanupWorktree()
wt.Close()

commitMsg := fmt.Sprintf("Address review findings (job %d)\n\n%s", currentFailedReview.JobID, summarizeAgentOutput(output))
newCommit, err := commitWithHookRetry(repoPath, commitMsg, addressAgent, opts.quiet)
Expand Down Expand Up @@ -1024,167 +1025,7 @@ func summarizeAgentOutput(output string) string {
return strings.Join(summary, "\n")
}

// createTempWorktree creates a temporary git worktree for isolated agent work
func createTempWorktree(repoPath string) (string, func(), error) {
worktreeDir, err := os.MkdirTemp("", "roborev-refine-")
if err != nil {
return "", nil, err
}

// Create the worktree (without --recurse-submodules for compatibility with older git).
// Suppress hooks via core.hooksPath=<null> — user hooks shouldn't run in internal worktrees.
cmd := exec.Command("git", "-C", repoPath, "-c", "core.hooksPath="+os.DevNull, "worktree", "add", "--detach", worktreeDir, "HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(worktreeDir)
return "", nil, fmt.Errorf("git worktree add: %w: %s", err, out)
}

// Initialize and update submodules in the worktree
initArgs := []string{"-C", worktreeDir}
if submoduleRequiresFileProtocol(worktreeDir) {
initArgs = append(initArgs, "-c", "protocol.file.allow=always")
}
initArgs = append(initArgs, "submodule", "update", "--init")
cmd = exec.Command("git", initArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run()
_ = os.RemoveAll(worktreeDir)
return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out)
}

updateArgs := []string{"-C", worktreeDir}
if submoduleRequiresFileProtocol(worktreeDir) {
updateArgs = append(updateArgs, "-c", "protocol.file.allow=always")
}
updateArgs = append(updateArgs, "submodule", "update", "--init", "--recursive")
cmd = exec.Command("git", updateArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run()
_ = os.RemoveAll(worktreeDir)
return "", nil, fmt.Errorf("git submodule update: %w: %s", err, out)
}

lfsCmd := exec.Command("git", "-C", worktreeDir, "lfs", "env")
if err := lfsCmd.Run(); err == nil {
cmd = exec.Command("git", "-C", worktreeDir, "lfs", "pull")
_ = cmd.Run()
}

cleanup := func() {
_ = exec.Command("git", "-C", repoPath, "worktree", "remove", "--force", worktreeDir).Run()
_ = os.RemoveAll(worktreeDir)
}

return worktreeDir, cleanup, nil
}

func submoduleRequiresFileProtocol(repoPath string) bool {
gitmodulesPaths := findGitmodulesPaths(repoPath)
if len(gitmodulesPaths) == 0 {
return false
}
for _, gitmodulesPath := range gitmodulesPaths {
file, err := os.Open(gitmodulesPath)
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
if !strings.EqualFold(strings.TrimSpace(parts[0]), "url") {
continue
}
url := strings.TrimSpace(parts[1])
if unquoted, err := strconv.Unquote(url); err == nil {
url = unquoted
}
if isFileProtocolURL(url) {
file.Close()
return true
}
}
file.Close()
}
return false
}

func findGitmodulesPaths(repoPath string) []string {
var paths []string
err := filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
if d.Name() == ".gitmodules" {
paths = append(paths, path)
}
return nil
})
if err != nil {
return nil
}
return paths
}

func isFileProtocolURL(url string) bool {
lower := strings.ToLower(url)
if strings.HasPrefix(lower, "file:") {
return true
}
if strings.HasPrefix(url, "/") || strings.HasPrefix(url, "./") || strings.HasPrefix(url, "../") {
return true
}
if len(url) >= 2 && isAlpha(url[0]) && url[1] == ':' {
return true
}
if strings.HasPrefix(url, `\\`) {
return true
}
return false
}

func isAlpha(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}

// applyWorktreeChanges applies changes from worktree to main repo via patch
func applyWorktreeChanges(repoPath, worktreePath string) error {
// Stage all changes in worktree
cmd := exec.Command("git", "-C", worktreePath, "add", "-A")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add in worktree: %w: %s", err, out)
}

// Get diff as patch
diffCmd := exec.Command("git", "-C", worktreePath, "diff", "--cached", "--binary")
diff, err := diffCmd.Output()
if err != nil {
return fmt.Errorf("git diff in worktree: %w", err)
}
if len(diff) == 0 {
return nil // No changes
}

// Apply patch to main repo
applyCmd := exec.Command("git", "-C", repoPath, "apply", "--binary", "-")
applyCmd.Stdin = bytes.NewReader(diff)
var stderr bytes.Buffer
applyCmd.Stderr = &stderr
if err := applyCmd.Run(); err != nil {
return fmt.Errorf("git apply: %w: %s", err, stderr.String())
}

return nil
}
// Worktree creation and patch operations are in internal/worktree package.

// commitWithHookRetry attempts git.CreateCommit and, on failure,
// runs the agent to fix whatever the hook complained about. Only
Expand Down
33 changes: 17 additions & 16 deletions cmd/roborev/refine_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/roborev-dev/roborev/internal/testutil"
"github.com/roborev-dev/roborev/internal/worktree"
)

func TestValidateRefineContext(t *testing.T) {
Expand Down Expand Up @@ -169,9 +170,9 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) {
// before the next iteration. Verify the directory is removed each time.
var prevPath string
for i := range 3 {
worktreePath, cleanup, err := createTempWorktree(repo.Root)
wt, err := worktree.Create(repo.Root)
if err != nil {
t.Fatalf("iteration %d: createTempWorktree failed: %v", i, err)
t.Fatalf("iteration %d: worktree.Create failed: %v", i, err)
}

// Verify previous worktree was cleaned up
Expand All @@ -182,13 +183,13 @@ func TestWorktreeCleanupBetweenIterations(t *testing.T) {
}

// Verify current worktree exists
if _, err := os.Stat(worktreePath); err != nil {
t.Fatalf("iteration %d: worktree %s should exist: %v", i, worktreePath, err)
if _, err := os.Stat(wt.Dir); err != nil {
t.Fatalf("iteration %d: worktree %s should exist: %v", i, wt.Dir, err)
}

// Simulate the explicit cleanup call (as done on error/no-change paths)
cleanup()
prevPath = worktreePath
wt.Close()
prevPath = wt.Dir
}

// Verify the last worktree was also cleaned up
Expand Down Expand Up @@ -221,19 +222,19 @@ func TestCreateTempWorktreeIgnoresHooks(t *testing.T) {
_ = out
}

// createTempWorktree should succeed because it suppresses hooks
worktreePath, cleanup, err := createTempWorktree(repo.Root)
// worktree.Create should succeed because it suppresses hooks
wt, err := worktree.Create(repo.Root)
if err != nil {
t.Fatalf("createTempWorktree should succeed with failing hook: %v", err)
t.Fatalf("worktree.Create should succeed with failing hook: %v", err)
}
defer cleanup()
defer wt.Close()

// Verify the worktree directory exists and has the file from the repo
if _, err := os.Stat(worktreePath); err != nil {
if _, err := os.Stat(wt.Dir); err != nil {
t.Fatalf("worktree directory should exist: %v", err)
}

baseFile := filepath.Join(worktreePath, "base.txt")
baseFile := filepath.Join(wt.Dir, "base.txt")
content, err := os.ReadFile(baseFile)
if err != nil {
t.Fatalf("expected base.txt in worktree: %v", err)
Expand Down Expand Up @@ -280,13 +281,13 @@ func TestCreateTempWorktreeInitializesSubmodules(t *testing.T) {
runMainGit("-c", "protocol.file.allow=always", "submodule", "add", submoduleRepo, "deps/sub")
runMainGit("commit", "-m", "add submodule")

worktreePath, cleanup, err := createTempWorktree(mainRepo)
wt, err := worktree.Create(mainRepo)
if err != nil {
t.Fatalf("createTempWorktree failed: %v", err)
t.Fatalf("worktree.Create failed: %v", err)
}
defer cleanup()
defer wt.Close()

if _, err := os.Stat(filepath.Join(worktreePath, "deps", "sub", "sub.txt")); err != nil {
if _, err := os.Stat(filepath.Join(wt.Dir, "deps", "sub", "sub.txt")); err != nil {
t.Fatalf("expected submodule file in worktree: %v", err)
}
}
Loading
Loading