diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index f16bbd0aa58c..678820bfbed6 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -2,29 +2,36 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { EnvironmentId, ProjectId, + GitCommandError, + GitManagerError, ThreadId, type ScopedThreadRef, type PullRequestDetailView, type ThreadPullRequestLink, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { act, type ReactNode, type ReactElement, type ComponentProps } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; -const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() => ({ - newThread: vi.fn(), - prepareThread: vi.fn(), - refresh: vi.fn(), - Wrapper: ({ children }: { children?: ReactNode }) => children, - Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( - <> - {render} - {children} - - ), -})); +const { newThread, prepareThread, refresh, addToast, updateToast, Wrapper, Trigger } = vi.hoisted( + () => ({ + newThread: vi.fn(), + prepareThread: vi.fn(), + refresh: vi.fn(), + addToast: vi.fn(), + updateToast: vi.fn(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), + }), +); vi.mock("~/state/session", async (original) => ({ ...(await original()), useEnvironmentScope: () => true, @@ -79,7 +86,7 @@ vi.mock("~/state/usePullRequestStack", () => ({ refresh, }), })); -vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), update: vi.fn() } })); +vi.mock("../ui/toast", () => ({ toastManager: { add: addToast, update: updateToast } })); vi.mock("../ui/tooltip", () => ({ TooltipProvider: Wrapper, Tooltip: Wrapper, @@ -232,6 +239,8 @@ beforeEach(() => { newThread .mockReset() .mockResolvedValue({ draftId: newDraftId, threadId: ThreadId.make("new-thread") }); + addToast.mockReset().mockReturnValue("checkout-toast"); + updateToast.mockReset(); prepareThread.mockReset().mockResolvedValue({ _tag: "Success", value: { branch: "feature", worktreePath: "/workspace/pr" }, @@ -358,6 +367,49 @@ describe.each([ }); }); +const checkoutDiagnostic = "This PR branch is already checked out in the main repo. Use Local."; + +it.each([ + new GitManagerError({ + cwd: "/server/private-project", + operation: "preparePullRequestThread", + detail: checkoutDiagnostic, + }), + new GitCommandError({ + cwd: "/server/private-project", + operation: "GitVcsDriver.createWorktree", + command: "git worktree add", + detail: checkoutDiagnostic, + }), +])("shows the server detail from a failed checkout ($_tag) without its path", async (error) => { + prepareThread.mockResolvedValueOnce({ _tag: "Failure", cause: Cause.fail(error) }); + await act(async () => { + renderer = create( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + />, + ); + }); + await click("In a separate worktree"); + expect(updateToast).toHaveBeenCalledWith("checkout-toast", { + type: "error", + title: "Could not prepare the pull request checkout", + description: expect.stringContaining(checkoutDiagnostic), + }); + expect(updateToast.mock.lastCall?.[1].description).not.toContain("/server/private-project"); +}); + it("changes the title only for the active tab's main scroller", async () => { const scrollers = new Map(); await act(async () => { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index d68983937f19..b4ebd21585cb 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -12,6 +12,7 @@ import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, type EnvironmentId, + GitCommandError, type PullRequestAction, type PullRequestMergeMethod, type PullRequestListEntry, @@ -21,6 +22,7 @@ import { resolveEnvironmentMachineKind, type ScopedThreadRef, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { ArrowDownUpIcon, ArrowLeftIcon, @@ -238,6 +240,8 @@ const ACTION_FAILURE_HINTS: Record = { const UPDATE_BRANCH_REBASE_FAILURE_HINT = "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work."; +const isGitCommandError = Schema.is(GitCommandError); + const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ { value: "summary", label: "Summary" }, { value: "guide", label: "Guide" }, @@ -1322,13 +1326,16 @@ export function PullRequestDetailPanel({ if (prepared._tag === "Failure") { setHandoff(null); // The server says what to do about it — that the branch is already checked out in the main - // repository, say — and that sentence is the only way out of the failure. - const detailMessage = - prepareThread.error instanceof Error ? prepareThread.error.message : null; + // repository, say — and that sentence is the only way out of the failure. A failed git + // command's message also names the server's project directory, so only its detail is shown. + const failure = squashAtomCommandFailure(prepared); toastManager.update(toastId, { type: "error", title: "Could not prepare the pull request checkout", - ...(detailMessage ? { description: detailMessage } : {}), + description: readableFailure( + isGitCommandError(failure) ? failure.detail : failure, + "Check that the branch still exists and is not checked out elsewhere, then try again.", + ), }); return; } diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index 3ca753df2b82..75311b5e1ac0 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -7,14 +7,16 @@ import { useAtomCommand } from "~/state/use-atom-command"; */ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { - EnvironmentId, - ProjectId, - PullRequestAction, - PullRequestDetail, - PullRequestMergeMethod, - PullRequestRef, +import { + GitCommandError, + type EnvironmentId, + type ProjectId, + type PullRequestAction, + type PullRequestDetail, + type PullRequestMergeMethod, + type PullRequestRef, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useCallback, useRef, useState } from "react"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useClientSettings, useEnvironmentSettings } from "~/hooks/useSettings"; @@ -38,6 +40,8 @@ import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullReq import { pullRequestEntryKey, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; /** Resolve on demand so hidden quick actions do not rebuild the legacy project grouping. */ +const isGitCommandError = Schema.is(GitCommandError); + export function usePullRequestDefaultMergeMethodResolver( environmentId: EnvironmentId, projectId: ProjectId, @@ -394,13 +398,16 @@ export function usePullRequestHandoffs({ if (prepared._tag === "Failure") { setHandoff(null); // The server says what to do about it — that the branch is already checked out in the main - // repository, say — and that sentence is the only way out of the failure. - const detailMessage = - prepareThread.error instanceof Error ? prepareThread.error.message : null; + // repository, say — and that sentence is the only way out of the failure. A failed git + // command's message also names the server's project directory, so only its detail is shown. + const failure = squashAtomCommandFailure(prepared); toastManager.update(toastId, { type: "error", title: "Could not prepare the pull request checkout", - ...(detailMessage ? { description: detailMessage } : {}), + description: readableFailure( + isGitCommandError(failure) ? failure.detail : failure, + "Check that the branch still exists and is not checked out elsewhere, then try again.", + ), }); return; }