diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 9b1334a9..85feb88d 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -6,6 +6,7 @@ import ( "io" "log" "slices" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -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 { diff --git a/cmd/roborev/tui/review_clipboard_test.go b/cmd/roborev/tui/review_clipboard_test.go index 6fe4cdb7..54014e14 100644 --- a/cmd/roborev/tui/review_clipboard_test.go +++ b/cmd/roborev/tui/review_clipboard_test.go @@ -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) { diff --git a/internal/daemon/server_jobs_test.go b/internal/daemon/server_jobs_test.go index 0df37f08..92c61b09 100644 --- a/internal/daemon/server_jobs_test.go +++ b/internal/daemon/server_jobs_test.go @@ -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 { @@ -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 1700000000 +0000\n"+ + "committer test 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",