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
13 changes: 13 additions & 0 deletions cmd/roborev/tui/handlers_msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"log"
"slices"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -734,12 +735,24 @@ func (m model) handleClipboardResultMsg(
) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.err = fmt.Errorf("copy failed: %w", msg.err)
m.setWarningFlash(clipboardErrorMessage(msg.err), 4*time.Second, msg.view)
} else {
m.setFlash("Copied to clipboard", 2*time.Second, msg.view)
}
return m, nil
}

// clipboardErrorMessage returns a user-friendly flash message for a
// clipboard write failure. The atotto/clipboard library returns a verbose
// "No clipboard utilities available..." error when no clipboard tool is
// installed; we substitute a shorter, actionable hint in that case.
func clipboardErrorMessage(err error) string {
if strings.Contains(err.Error(), "No clipboard utilities available") {
return "Copy failed: install xclip, wl-clipboard, or xsel"
}
return fmt.Sprintf("Copy failed: %v", err)
}

// handleSavePatchResultMsg processes save-patch-to-file results.
func (m model) handleSavePatchResultMsg(msg savePatchResultMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
Expand Down
35 changes: 34 additions & 1 deletion cmd/roborev/tui/review_clipboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,47 @@ func TestTUIYankCopyShowsFlashMessage(t *testing.T) {
}

func TestTUIYankCopyShowsErrorOnFailure(t *testing.T) {
assert := assert.New(t)
m := newModel(localhostEndpoint, withExternalIODisabled())
m.currentView = viewQueue
m.width = 80
m.height = 24
m.jobs = []storage.ReviewJob{
makeJob(1, withRef("abc123"), withAgent("test"), withStatus(storage.JobStatusDone)),
}

m, _ = updateModel(t, m, clipboardResultMsg{err: fmt.Errorf("clipboard not available"), view: viewQueue})

require.Error(t, m.err)
assert.Contains(m.err.Error(), "copy failed")

assert.True(m.flashWarning, "expected warning-styled flash")
assert.Equal("Copy failed: clipboard not available", m.flashMessage)
assert.Equal(viewQueue, m.flashView)
assert.False(m.flashExpiresAt.IsZero())

output := m.renderQueueView()
assert.Contains(output, "Copy failed: clipboard not available")
}

func TestTUIYankCopyShowsFriendlyMessageWhenNoClipboardTool(t *testing.T) {
assert := assert.New(t)
m := newModel(localhostEndpoint, withExternalIODisabled())
m.currentView = viewQueue
m.width = 80
m.height = 24
m.jobs = []storage.ReviewJob{
makeJob(1, withRef("abc123"), withAgent("test"), withStatus(storage.JobStatusDone)),
}

atottoErr := fmt.Errorf("No clipboard utilities available. Please install xsel, xclip, wl-clipboard or Termux:API add-on for termux-clipboard-get/set.")
m, _ = updateModel(t, m, clipboardResultMsg{err: atottoErr, view: viewQueue})

assert.True(m.flashWarning)
assert.Equal("Copy failed: install xclip, wl-clipboard, or xsel", m.flashMessage)

assert.Contains(t, m.err.Error(), "copy failed")
output := m.renderQueueView()
assert.Contains(output, "Copy failed: install xclip, wl-clipboard, or xsel")
}

func TestTUIYankFlashViewNotAffectedByViewChange(t *testing.T) {
Expand Down
87 changes: 40 additions & 47 deletions internal/daemon/server_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,18 +466,20 @@ func TestHandleEnqueueExcludedCommitPattern(t *testing.T) {
}
})

// This test corrupts a git object, so it must run last
// since the repo becomes unusable afterward.
// This test corrupts a branch's parent chain, so it must run last
// since the corrupt-range branch becomes unwalkable afterward.
t.Run("range with corrupt mid-commit enqueues normally",
func(t *testing.T) {
// Removing a mid-range commit object makes
// GetRangeCommits fail, so the exclusion block
// is skipped entirely and the job is enqueued.
// (The allRead guard is additional defense for
// transient I/O failures where GetRangeCommits
// succeeds but individual GetCommitInfo calls
// fail — git object corruption can't isolate
// those two calls.)
// Build a synthetic tip whose `parent` line points at a SHA
// that does not exist in the object store. GetRangeCommits
// walks back from tip, can't load the fake parent, and
// fails — same failure mode as a corrupted/missing object,
// but independent of the on-disk object layout (which is
// fragile on Windows due to packing and AV behavior).
// (The allRead guard is additional defense for transient
// I/O failures where GetRangeCommits succeeds but
// individual GetCommitInfo calls fail — git object
// corruption can't isolate those two calls.)
branchCmd := exec.Command("git", "-C", repoDir,
"checkout", "-b", "corrupt-range")
if out, err := branchCmd.CombinedOutput(); err != nil {
Expand All @@ -487,45 +489,36 @@ func TestHandleEnqueueExcludedCommitPattern(t *testing.T) {
}
base := testutil.GetHeadSHA(t, repoDir)

// Three excluded commits; corrupt the middle one.
for i := range 3 {
cmd := exec.Command("git", "-C", repoDir,
"commit", "--allow-empty",
"-m", fmt.Sprintf("[wip] corrupt %d", i))
if out, err := cmd.CombinedOutput(); err != nil {
require.Condition(t, func() bool {
return false
}, "commit failed: %v\n%s", err, out)
}
}
tip := testutil.GetHeadSHA(t, repoDir)

// Walk back to the middle commit (parent of tip).
midCmd := exec.Command("git", "-C", repoDir,
"rev-parse", "HEAD~1")
midOut, err := midCmd.Output()
if err != nil {
require.Condition(t, func() bool {
return false
}, "rev-parse HEAD~1: %v", err)
}
mid := strings.TrimSpace(string(midOut))

objFile := filepath.Join(
repoDir, ".git", "objects",
mid[:2], mid[2:],
)
if err := os.Remove(objFile); err != nil {
require.Condition(t, func() bool {
return false
}, "remove object: %v", err)
treeOut, err := exec.Command("git", "-C", repoDir,
"rev-parse", "HEAD^{tree}").Output()
require.NoError(t, err, "rev-parse tree")
tree := strings.TrimSpace(string(treeOut))

const fakeParent = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
content := fmt.Sprintf(
"tree %s\nparent %s\n"+
"author test <t@example.com> 1700000000 +0000\n"+
"committer test <t@example.com> 1700000000 +0000\n"+
"\nbroken parent\n",
tree, fakeParent)
hashCmd := exec.Command("git", "-C", repoDir,
"hash-object", "-w", "-t", "commit", "--stdin")
hashCmd.Stdin = strings.NewReader(content)
hashOut, err := hashCmd.Output()
require.NoError(t, err, "hash-object")
tip := strings.TrimSpace(string(hashOut))

if out, err := exec.Command("git", "-C", repoDir,
"update-ref", "refs/heads/corrupt-range", tip).
CombinedOutput(); err != nil {
require.NoError(t, err, "update-ref: %s", out)
}

// ResolveSHA succeeds for both endpoints (base
// and tip are intact), but GetRangeCommits fails
// because git can't walk through the missing
// middle commit. The exclusion block is skipped
// and the job is enqueued normally.
// ResolveSHA succeeds for both endpoints (base and the
// synthetic tip both exist as objects), but
// GetRangeCommits fails because git can't load tip's
// fake parent. The exclusion block is skipped and the
// job is enqueued normally.
ref := base + ".." + tip
reqData := EnqueueRequest{
RepoPath: repoDir, GitRef: ref, Agent: "test",
Expand Down
Loading