diff --git a/apps/app/src/components/layout/content-panel/panels/README.md b/apps/app/src/components/layout/content-panel/panels/README.md index 9a28df7ef..dc9f8d034 100644 --- a/apps/app/src/components/layout/content-panel/panels/README.md +++ b/apps/app/src/components/layout/content-panel/panels/README.md @@ -3,13 +3,14 @@ Mock content, real wiring. These remain to exercise the panel shapes that do not yet have real features behind them: -| | arity | `create` | what it demonstrates | -| --------- | --------------------- | -------- | --------------------------------------------------- | -| `diff` | singleton | — | the thin default handle; opening twice is one panel | -| `browser` | family (`key: tabId`) | ✓ | an instance with its own store (url, loading) | +| | arity | `create` | what it demonstrates | +| --------- | --------------------- | -------- | --------------------------------------------- | +| `browser` | family (`key: tabId`) | ✓ | an instance with its own store (url, loading) | The real Files entry panel and path-keyed file viewer live in `features/files/`. The real Terminal panel lives in `features/terminal/`. +The Review panel (workspace diffs vs uncommitted, committed, or another branch) +lives in `features/review/`. There is one tab strip and it belongs to the host. A panel that wants "several of a thing" — two shells, two files — opens several panels, so the strip stays diff --git a/apps/app/src/components/layout/content-panel/panels/diff-panel.tsx b/apps/app/src/components/layout/content-panel/panels/diff-panel.tsx deleted file mode 100644 index 620d99109..000000000 --- a/apps/app/src/components/layout/content-panel/panels/diff-panel.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { FileDiffIcon } from "lucide-react"; - -import { definePanel } from "../react/view"; - -// Placeholder content — see ./README.md. -const MOCK_FILES = [ - { path: "packages/server/src/harness/session.ts", added: 24, removed: 6 }, - { path: "apps/app/src/features/chat/chat.tsx", added: 8, removed: 8 }, - { path: "packages/contract/src/domain.ts", added: 3, removed: 0 }, -]; - -const MOCK_HUNK = [ - { sign: " ", text: " const session = yield* manager.sessionFor(ref)" }, - { sign: "-", text: " return session.snapshot()" }, - { sign: "+", text: " const snapshot = session.snapshot()" }, - { sign: "+", text: " yield* bus.publish(sessionUpdated(ref, snapshot))" }, - { sign: "+", text: " return snapshot" }, - { sign: " ", text: "}" }, -]; - -/** - * A singleton with no `create`: the default handle is everything it needs. The - * whole definition is data plus one render function. - */ -export const diffPanel = definePanel({ - type: "diff", - label: "Diff", - view: { - icon: FileDiffIcon, - render: () => , - }, -}); - -function DiffPanelView() { - return ( -
- -
-        {MOCK_HUNK.map((line) => (
-          
- {line.sign} - {line.text} -
- ))} -
-
- ); -} diff --git a/apps/app/src/features/chat/runtime/chat-update.test.ts b/apps/app/src/features/chat/runtime/chat-update.test.ts index 25273e347..4c53980f4 100644 --- a/apps/app/src/features/chat/runtime/chat-update.test.ts +++ b/apps/app/src/features/chat/runtime/chat-update.test.ts @@ -418,7 +418,9 @@ describe("updateChat", () => { const prompting = createChatState(); const pending = requested("sending", "in flight"); - prompting.outgoing = [{ message: pending.message, parts: pending.parts, status: "sending" }]; + prompting.outgoing = [ + { message: pending.message, parts: pending.parts, status: "sending", delivery: "follow-up" }, + ]; expectIgnored(prompting, { type: "outgoingCompleted", messageId: "late", diff --git a/apps/app/src/features/review/review-diff-adapter.tsx b/apps/app/src/features/review/review-diff-adapter.tsx new file mode 100644 index 000000000..4be2b7108 --- /dev/null +++ b/apps/app/src/features/review/review-diff-adapter.tsx @@ -0,0 +1,143 @@ +import { parseDiffFromFile } from "@pierre/diffs"; +import { + CodeView, + type CodeViewHandle, + type CodeViewItem, + type CodeViewReactOptions, +} from "@pierre/diffs/react"; +import type { GitFileDiff } from "@vibest/contract/git"; +import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; + +const DIFF_UNSAFE_CSS = ` + :host { + --diffs-font-family: var(--font-mono); + --diffs-light-bg: var(--background); + --diffs-dark-bg: var(--background); + --diffs-light: var(--foreground); + --diffs-dark: var(--foreground); + --diffs-fg-number-override: var(--muted-foreground); + --diffs-bg-buffer-override: var(--background); + --diffs-bg-context-override: var(--background); + --diffs-bg-context-gutter-override: var(--background); + --diffs-bg-separator-override: var(--border); + min-height: 100%; + width: 100%; + } + + [data-diffs-header] { + cursor: pointer; + } +`; + +const getAppThemeType = (): "dark" | "light" => + document.documentElement.classList.contains("dark") ? "dark" : "light"; + +const subscribeToAppTheme = (listener: () => void): (() => void) => { + const observer = new MutationObserver(listener); + observer.observe(document.documentElement, { + attributeFilter: ["class"], + attributes: true, + }); + return () => observer.disconnect(); +}; + +function parseReviewDiff(diff: GitFileDiff) { + return parseDiffFromFile( + diff.oldContents === null + ? null + : { name: diff.oldPath ?? diff.path, contents: diff.oldContents }, + diff.newContents === null ? null : { name: diff.path, contents: diff.newContents }, + ); +} + +function itemIdFromInstance(instance: object): string | undefined { + if (!("fileDiff" in instance)) return undefined; + const fileDiff = instance.fileDiff; + if (fileDiff === null || typeof fileDiff !== "object" || !("name" in fileDiff)) return undefined; + const name = fileDiff.name; + return typeof name === "string" ? name : undefined; +} + +export function ReviewDiffAdapter({ + diffs, + locatePath, + locateRequest, +}: { + diffs: ReadonlyArray; + locatePath?: string; + locateRequest: number; +}) { + const themeType = useSyncExternalStore( + subscribeToAppTheme, + getAppThemeType, + () => "light" as const, + ); + const codeViewRef = useRef>(null); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [appliedLocate, setAppliedLocate] = useState(locateRequest); + if (locateRequest !== appliedLocate) { + setAppliedLocate(locateRequest); + if (locatePath !== undefined && collapsed.has(locatePath)) { + const next = new Set(collapsed); + next.delete(locatePath); + setCollapsed(next); + } + } + const fileDiffs = useMemo( + () => diffs.map((diff) => ({ path: diff.path, fileDiff: parseReviewDiff(diff) })), + [diffs], + ); + + const items = useMemo>( + () => + fileDiffs.map(({ path, fileDiff }) => ({ + id: path, + type: "diff", + fileDiff, + collapsed: collapsed.has(path), + })), + [collapsed, fileDiffs], + ); + + const options = useMemo( + () => ({ + overflow: "scroll", + stickyHeaders: true, + theme: { dark: "pierre-dark", light: "pierre-light" }, + themeType, + unsafeCSS: DIFF_UNSAFE_CSS, + onPostRender(node, instance, phase) { + if (phase === "unmount") return; + const header = node.shadowRoot?.querySelector("[data-diffs-header]"); + if (!(header instanceof HTMLElement)) return; + const id = itemIdFromInstance(instance); + if (id !== undefined) header.dataset.reviewPath = id; + if (header.dataset.reviewCollapseBound === "true") return; + header.dataset.reviewCollapseBound = "true"; + header.addEventListener("click", () => { + const path = header.dataset.reviewPath; + if (path === undefined) return; + setCollapsed((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }); + }, + }), + [themeType], + ); + + useLayoutEffect(() => { + if (locatePath === undefined) return; + if (!fileDiffs.some((entry) => entry.path === locatePath)) return; + codeViewRef.current?.scrollTo({ type: "item", id: locatePath, align: "start" }); + }, [fileDiffs, locatePath, locateRequest]); + + return ( +
+ +
+ ); +} diff --git a/apps/app/src/features/review/review-diff-pane.tsx b/apps/app/src/features/review/review-diff-pane.tsx new file mode 100644 index 000000000..6ac837807 --- /dev/null +++ b/apps/app/src/features/review/review-diff-pane.tsx @@ -0,0 +1,138 @@ +import { ORPCError } from "@orpc/client"; +import type { GitFileDiff } from "@vibest/contract/git"; +import { Spinner } from "@vibest/ui/components/spinner"; +import { FileDiffIcon } from "lucide-react"; +import { lazy, Suspense } from "react"; + +import { emptyReviewMessage } from "./review-file-status"; +import { ReviewState } from "./review-state"; +import type { GitDiffsQuery } from "./use-git-diffs"; +import type { GitReviewQuery } from "./use-git-review"; + +const ReviewDiffAdapter = lazy(() => + import("./review-diff-adapter").then((module) => ({ default: module.ReviewDiffAdapter })), +); + +export function ReviewDiffPane({ + review, + diffs, + path, + locateRequest, +}: { + review: GitReviewQuery; + diffs: GitDiffsQuery; + path?: string; + locateRequest: number; +}) { + if (review.data !== undefined && review.data.files.length === 0) { + return ( + + {emptyReviewMessage(review.data)} + + ); + } + + if (diffs.some((diff) => diff.isPending)) { + return ( +
+ +
+ ); + } + + const loaded: GitFileDiff[] = []; + let firstError: Error | undefined; + for (const diff of diffs) { + if (diff.data !== undefined) { + loaded.push(diff.data); + continue; + } + if (diff.isError && diff.error !== null && !isSkippedDiffError(diff.error)) { + firstError ??= diff.error; + } + } + + if (loaded.length === 0 && firstError !== undefined) { + return ( + void review.refetch()}> + {diffErrorMessage(firstError)} + + ); + } + + if (loaded.length === 0) { + return ( + + Select a changed file in the tree to jump to its diff. + + ); + } + + return ( +
+ + +
+ } + > + + + + ); +} + +function isSkippedDiffError(error: Error): boolean { + return ( + error instanceof ORPCError && (error.code === "BINARY_FILE" || error.code === "FILE_TOO_LARGE") + ); +} + +function diffErrorTitle(error: Error): string { + if (!(error instanceof ORPCError)) return "Unable to load diff"; + switch (error.code) { + case "NOT_FOUND": + return "File is no longer in the review"; + case "BINARY_FILE": + return "Binary preview unavailable"; + case "FILE_TOO_LARGE": + return "File too large to preview"; + case "REF_NOT_FOUND": + return "Compare branch not found"; + default: + return "Unable to load diff"; + } +} + +function diffErrorMessage(error: Error): string { + if (!(error instanceof ORPCError)) return error.message; + switch (error.code) { + case "NOT_FOUND": + return "The file may have been committed, reverted, or renamed. Refresh the review."; + case "BINARY_FILE": + return "Binary preview unavailable."; + case "FILE_TOO_LARGE": { + const data = error.data as { size?: number; limit?: number } | undefined; + const size = data?.size; + const limit = data?.limit; + if (size !== undefined && limit !== undefined) { + return `${formatBytes(size)} exceeds the ${formatBytes(limit)} preview limit.`; + } + return "File too large to preview."; + } + case "PATH_ESCAPE": + return "This path resolves outside the project workspace."; + case "REF_NOT_FOUND": + return "Pick a local branch or a remote-tracking ref that already exists."; + default: + return error.message; + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kibibytes = bytes / 1024; + if (kibibytes < 1024) return `${kibibytes.toFixed(1)} KiB`; + return `${(kibibytes / 1024).toFixed(1)} MiB`; +} diff --git a/apps/app/src/features/review/review-file-status.test.ts b/apps/app/src/features/review/review-file-status.test.ts new file mode 100644 index 000000000..c72800b54 --- /dev/null +++ b/apps/app/src/features/review/review-file-status.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + emptyReviewMessage, + isReviewMode, + pierreGitStatus, + reviewHeading, + splitCompareRefs, +} from "./review-file-status"; + +describe("reviewHeading", () => { + it("names uncommitted work on the current branch", () => { + expect( + reviewHeading({ mode: "uncommitted", branch: "main", baseBranch: null, other: null }), + ).toBe("Uncommitted changes on main"); + }); + + it("names a committed review against its base", () => { + expect( + reviewHeading({ + mode: "committed", + branch: "feature/auth", + baseBranch: "origin/main", + other: null, + }), + ).toBe("feature/auth → origin/main"); + }); + + it("names a branch comparison including remotes", () => { + expect( + reviewHeading({ + mode: "branch", + branch: "feature/auth", + baseBranch: "origin/main", + other: "origin/main", + }), + ).toBe("feature/auth → origin/main"); + }); + + it("falls back when the branch name is missing", () => { + expect( + reviewHeading({ mode: "uncommitted", branch: null, baseBranch: null, other: null }), + ).toBe("Uncommitted changes"); + }); +}); + +describe("emptyReviewMessage", () => { + it("explains a clean working tree", () => { + expect(emptyReviewMessage({ mode: "uncommitted", baseBranch: null, other: null })).toBe( + "The working tree matches HEAD.", + ); + }); +}); + +describe("isReviewMode", () => { + it("accepts the three compare modes", () => { + expect(isReviewMode("uncommitted")).toBe(true); + expect(isReviewMode("committed")).toBe(true); + expect(isReviewMode("branch")).toBe(true); + expect(isReviewMode("pr")).toBe(false); + }); +}); + +describe("splitCompareRefs", () => { + it("keeps slashed local names out of the remote group", () => { + expect( + splitCompareRefs( + ["main", "feature/oauth", "origin/main", "origin/HEAD"], + ["origin/main", "origin/HEAD"], + ), + ).toEqual({ + local: ["main", "feature/oauth"], + remote: ["origin/main", "origin/HEAD"], + }); + }); +}); + +describe("pierreGitStatus", () => { + it("maps copied to modified because Pierre has no copied badge", () => { + expect(pierreGitStatus("copied")).toBe("modified"); + expect(pierreGitStatus("renamed")).toBe("renamed"); + }); +}); diff --git a/apps/app/src/features/review/review-file-status.ts b/apps/app/src/features/review/review-file-status.ts new file mode 100644 index 000000000..4687b32d4 --- /dev/null +++ b/apps/app/src/features/review/review-file-status.ts @@ -0,0 +1,101 @@ +import type { GitReviewFile, GitReviewFileStatus, GitReviewMode } from "@vibest/contract/git"; + +export const REVIEW_STATUS_LABEL: Record = { + modified: "Modified", + added: "Added", + deleted: "Deleted", + renamed: "Renamed", + copied: "Copied", +}; + +export const REVIEW_STATUS_BADGE: Record = { + modified: "M", + added: "A", + deleted: "D", + renamed: "R", + copied: "C", +}; + +export const REVIEW_MODE_ITEMS = [ + { value: "uncommitted", label: "Uncommitted" }, + { value: "committed", label: "Committed" }, + { value: "branch", label: "vs branch" }, +] as const; + +export function isReviewMode(value: unknown): value is GitReviewMode { + return value === "uncommitted" || value === "committed" || value === "branch"; +} + +export function reviewHeading(review: { + mode: GitReviewMode; + branch: string | null; + baseBranch: string | null; + other: string | null; +}): string { + switch (review.mode) { + case "uncommitted": + return review.branch === null + ? "Uncommitted changes" + : `Uncommitted changes on ${review.branch}`; + case "committed": + if (review.branch !== null && review.baseBranch !== null) { + return `${review.branch} → ${review.baseBranch}`; + } + return "Committed changes"; + case "branch": { + const target = review.other ?? review.baseBranch; + if (review.branch !== null && target !== null) return `${review.branch} → ${target}`; + return "Branch comparison"; + } + } +} + +export function emptyReviewMessage(review: { + mode: GitReviewMode; + baseBranch: string | null; + other: string | null; +}): string { + switch (review.mode) { + case "uncommitted": + return "The working tree matches HEAD."; + case "committed": + return review.baseBranch === null + ? "HEAD matches the default branch." + : `This branch has no committed changes against ${review.baseBranch}.`; + case "branch": { + const target = review.other ?? review.baseBranch; + return target === null + ? "No changes against the selected branch." + : `No changes against ${target}.`; + } + } +} + +export function splitCompareRefs( + branches: ReadonlyArray, + remotes: ReadonlyArray, +): { + local: string[]; + remote: string[]; +} { + const remoteSet = new Set(remotes); + const local: string[] = []; + const remote: string[] = []; + for (const name of branches) { + if (remoteSet.has(name)) remote.push(name); + else local.push(name); + } + return { local, remote }; +} + +export function pierreGitStatus( + status: GitReviewFileStatus, +): "added" | "deleted" | "modified" | "renamed" { + return status === "copied" ? "modified" : status; +} + +export function reviewGitStatusEntries( + files: ReadonlyArray, +): ReadonlyArray<{ path: string; status: ReturnType }> { + return files.map((file) => ({ path: file.path, status: pierreGitStatus(file.status) })); +} diff --git a/apps/app/src/features/review/review-panel.tsx b/apps/app/src/features/review/review-panel.tsx new file mode 100644 index 000000000..82d1171fd --- /dev/null +++ b/apps/app/src/features/review/review-panel.tsx @@ -0,0 +1,226 @@ +import { ORPCError } from "@orpc/client"; +import type { GitReviewMode } from "@vibest/contract/git"; +import { Spinner } from "@vibest/ui/components/spinner"; +import { GitCompareIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { asRecord, type PanelHandle } from "@/components/layout/content-panel/model/panel"; +import { useContentPanel } from "@/components/layout/content-panel/react/hooks"; +import { definePanel } from "@/components/layout/content-panel/react/view"; + +import { ReviewDiffPane } from "./review-diff-pane"; +import { isReviewMode, reviewHeading } from "./review-file-status"; +import { ReviewState } from "./review-state"; +import { ReviewToolbar } from "./review-toolbar"; +import { ReviewTreePane } from "./review-tree-pane"; +import { ReviewWorkspaceLayout } from "./review-workspace-layout"; +import { useGitBranch } from "./use-git-branch"; +import { useGitDiffs } from "./use-git-diffs"; +import { useGitReview } from "./use-git-review"; +import { useSessionWorkspace } from "./use-session-workspace"; +import { useWorkspaceTree } from "./use-workspace-tree"; + +export interface ReviewPayload { + readonly mode?: GitReviewMode; + readonly other?: string; + readonly path?: string; +} + +export const reviewPanel = definePanel({ + type: "review", + label: "Review", + newPayload: () => ({}), + parse: (raw) => { + const record = asRecord(raw); + if (record === null) return {}; + const path = typeof record.path === "string" ? record.path : undefined; + const mode = isReviewMode(record.mode) ? record.mode : undefined; + const other = typeof record.other === "string" ? record.other : undefined; + return { + ...(path === undefined ? {} : { path }), + ...(mode === undefined ? {} : { mode }), + ...(other === undefined ? {} : { other }), + }; + }, + view: { + icon: GitCompareIcon, + render: (instance) => , + }, +}); + +function ReviewPanelView({ instance }: { instance: PanelHandle }) { + const workspace = useSessionWorkspace(); + const panel = useContentPanel(); + const cwd = workspace.data?.path; + const mode = instance.payload.mode ?? "uncommitted"; + const branch = useGitBranch(cwd); + const other = + mode === "branch" + ? (instance.payload.other ?? branch.data?.defaultBranch ?? undefined) + : undefined; + const review = useGitReview(cwd, mode, other); + const tree = useWorkspaceTree(cwd); + const diffs = useGitDiffs(cwd, review.data?.files ?? [], mode, other); + const [locateRequest, setLocateRequest] = useState(0); + const selectedPath = instance.payload.path; + + const selectFile = useCallback( + (path: string) => { + instance.setPayload((current) => ({ ...current, path })); + setLocateRequest((current) => current + 1); + }, + [instance], + ); + + const setMode = useCallback( + (next: GitReviewMode) => { + instance.setPayload((current) => { + if (next === "branch") { + const nextOther = current.other ?? branch.data?.defaultBranch ?? undefined; + return { + ...current, + mode: next, + ...(nextOther === undefined ? {} : { other: nextOther }), + }; + } + const { other: _other, ...rest } = current; + return { ...rest, mode: next }; + }); + }, + [branch.data?.defaultBranch, instance], + ); + + const setOther = useCallback( + (next: string) => { + instance.setPayload((current) => ({ ...current, mode: "branch", other: next })); + }, + [instance], + ); + + const heading = useMemo( + () => (review.data === undefined ? "" : reviewHeading(review.data)), + [review.data], + ); + + if (workspace.isPending) { + return ( +
+ +
+ ); + } + + if (workspace.isError) { + return ( + void workspace.refetch()}> + The project list could not be loaded. + + ); + } + + if (!workspace.data || cwd === undefined || panel === null) { + return ( + + This session no longer resolves to an imported project. + + ); + } + + if (mode === "branch" && other === undefined && !branch.isPending) { + return ( + + This repository has no local default branch or remote-tracking ref to compare against. + + ); + } + + if ( + (review.isPending && review.data === undefined) || + (mode === "branch" && other === undefined) + ) { + return ( +
+ +
+ ); + } + + if (review.isError && review.data === undefined) { + return ( + void review.refetch()}> + {reviewErrorMessage(review.error)} + + ); + } + + const refreshing = review.isFetching || branch.isFetching || tree.isFetching; + const refresh = (): void => { + void Promise.all([ + review.refetch(), + branch.refetch(), + tree.refetch(), + ...diffs.map((diff) => diff.refetch()), + ]); + }; + + return ( + + } + filesLabel={workspace.data.name} + preview={ + + } + toolbar={ + + } + /> + ); +} + +function reviewErrorTitle(error: Error): string { + if (!(error instanceof ORPCError)) return "Unable to load review"; + switch (error.code) { + case "NOT_REPOSITORY": + return "Not a Git repository"; + case "REF_NOT_FOUND": + return "Compare branch not found"; + default: + return "Unable to load review"; + } +} + +function reviewErrorMessage(error: Error): string { + if (!(error instanceof ORPCError)) return error.message; + switch (error.code) { + case "NOT_REPOSITORY": + return "Open a Git project to review uncommitted work, commits, or another branch."; + case "REF_NOT_FOUND": + return "Pick a local branch or a remote-tracking ref that already exists."; + default: + return error.message; + } +} diff --git a/apps/app/src/features/review/review-state.tsx b/apps/app/src/features/review/review-state.tsx new file mode 100644 index 000000000..fcbb61c6c --- /dev/null +++ b/apps/app/src/features/review/review-state.tsx @@ -0,0 +1,44 @@ +import { Button } from "@vibest/ui/components/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@vibest/ui/components/empty"; +import { GitCompareIcon, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +export function ReviewState({ + title, + children, + onRetry, + icon: Icon = GitCompareIcon, + prominentIcon = false, +}: { + title: string; + children: ReactNode; + onRetry?: () => void; + icon?: LucideIcon; + prominentIcon?: boolean; +}) { + return ( + + + + + + {title} + {children} + + {onRetry ? ( + + + + ) : null} + + ); +} diff --git a/apps/app/src/features/review/review-toolbar.tsx b/apps/app/src/features/review/review-toolbar.tsx new file mode 100644 index 000000000..7227e88c6 --- /dev/null +++ b/apps/app/src/features/review/review-toolbar.tsx @@ -0,0 +1,141 @@ +import type { GitBranch, GitReviewMode } from "@vibest/contract/git"; +import { Button } from "@vibest/ui/components/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectTrigger, + SelectValue, +} from "@vibest/ui/components/select"; +import { cn } from "@vibest/ui/lib/utils"; +import { ChevronDownIcon, RefreshCwIcon } from "lucide-react"; +import type { ComponentProps } from "react"; + +import { REVIEW_MODE_ITEMS, splitCompareRefs } from "./review-file-status"; + +function GhostSelectTrigger({ + className, + placeholder, + ...props +}: Omit, "render"> & { + placeholder?: string; +}) { + // Drop SelectTrigger's field chrome and its hard-coded up/down icon. + return ( + ( + + )} + /> + ); +} + +export function ReviewToolbar({ + mode, + other, + branch, + heading, + refreshing, + onModeChange, + onOtherChange, + onRefresh, +}: { + mode: GitReviewMode; + other: string | undefined; + branch: GitBranch | undefined; + heading: string; + refreshing: boolean; + onModeChange: (mode: GitReviewMode) => void; + onOtherChange: (other: string) => void; + onRefresh: () => void; +}) { + const refs = splitCompareRefs(branch?.branches ?? [], branch?.remotes ?? []); + const otherValue = other ?? branch?.defaultBranch ?? null; + const otherItems = (branch?.branches ?? []).map((name) => ({ label: name, value: name })); + + return ( +
+ + {mode === "branch" ? ( + + ) : ( +

+ {heading} +

+ )} + +
+ ); +} diff --git a/apps/app/src/features/review/review-tree-adapter.tsx b/apps/app/src/features/review/review-tree-adapter.tsx new file mode 100644 index 000000000..0a61b54e8 --- /dev/null +++ b/apps/app/src/features/review/review-tree-adapter.tsx @@ -0,0 +1,132 @@ +import { FileTree as PierreFileTree } from "@pierre/trees/react"; +import type { WorkspaceTreeEntry } from "@vibest/contract/fs"; +import { + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; + +import { + getReviewFileTree, + isOpenableTreeEntry, + symlinkDescription, + syncReviewFileTree, +} from "./review-tree"; + +const TREE_STYLE = { + height: "100%", + width: "100%", + "--trees-bg-override": "var(--background)", + "--trees-bg-muted-override": "var(--muted)", + "--trees-border-color-override": "var(--border)", + "--trees-fg-override": "var(--foreground)", + "--trees-fg-muted-override": "var(--muted-foreground)", + "--trees-focus-ring-color-override": "var(--ring)", + "--trees-font-family-override": "var(--font-mono)", + "--trees-font-size-override": "12px", + "--trees-selected-bg-override": "var(--accent)", + "--trees-selected-fg-override": "var(--accent-foreground)", +} as CSSProperties; + +function pathFromComposedEvent(event: MouseEvent): string | null { + for (const target of event.nativeEvent.composedPath()) { + if (target instanceof HTMLElement && target.dataset.itemPath !== undefined) { + return target.dataset.itemPath; + } + } + return null; +} + +export function ReviewTreeAdapter({ + sessionId, + entries, + gitStatus, + onSelectFile, +}: { + sessionId: string; + entries: ReadonlyArray; + gitStatus: ReadonlyArray<{ + path: string; + status: "added" | "deleted" | "modified" | "renamed"; + }>; + onSelectFile: (path: string) => void; +}) { + const state = useMemo(() => getReviewFileTree(sessionId), [sessionId]); + const containerRef = useRef(null); + + useLayoutEffect(() => { + syncReviewFileTree(state, entries, gitStatus); + }, [entries, gitStatus, state]); + + useEffect(() => { + const host = containerRef.current?.querySelector("file-tree-container"); + const shadowRoot = host?.shadowRoot; + if (shadowRoot === undefined || shadowRoot === null) return; + + const annotateRows = (): void => { + for (const row of shadowRoot.querySelectorAll("[data-item-path]")) { + const path = row.dataset.itemPath; + const entry = path === undefined ? undefined : state.entryByPath.get(path); + const description = entry === undefined ? null : symlinkDescription(entry); + if (description === null) { + row.removeAttribute("aria-description"); + row.removeAttribute("aria-disabled"); + continue; + } + row.setAttribute("aria-description", description); + if (isOpenableTreeEntry(entry)) row.removeAttribute("aria-disabled"); + else row.setAttribute("aria-disabled", "true"); + } + }; + + annotateRows(); + const observer = new MutationObserver(annotateRows); + observer.observe(shadowRoot, { + attributeFilter: ["data-item-path"], + attributes: true, + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, [entries, state]); + + const openPath = (path: string | null): void => { + if (path === null) return; + const entry = state.entryByPath.get(path); + if (!isOpenableTreeEntry(entry)) return; + state.model.getItem(path)?.select(); + onSelectFile(path); + }; + + const handleClick = (event: MouseEvent): void => { + openPath(pathFromComposedEvent(event)); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Enter" || event.defaultPrevented) return; + const focusedPath = state.model.getFocusedPath(); + if ( + !isOpenableTreeEntry(focusedPath === null ? undefined : state.entryByPath.get(focusedPath)) + ) { + return; + } + event.preventDefault(); + openPath(focusedPath); + }; + + return ( +
+ +
+ ); +} diff --git a/apps/app/src/features/review/review-tree-pane.tsx b/apps/app/src/features/review/review-tree-pane.tsx new file mode 100644 index 000000000..7b172a986 --- /dev/null +++ b/apps/app/src/features/review/review-tree-pane.tsx @@ -0,0 +1,124 @@ +import { ORPCError } from "@orpc/client"; +import type { GitReviewFile } from "@vibest/contract/git"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from "@vibest/ui/components/empty"; +import { Spinner } from "@vibest/ui/components/spinner"; +import { FilesIcon, TriangleAlertIcon } from "lucide-react"; +import { lazy, Suspense, useMemo } from "react"; + +import { reviewGitStatusEntries } from "./review-file-status"; +import { unionDeletedReviewEntries } from "./review-tree"; +import type { WorkspaceTreeQuery } from "./use-workspace-tree"; + +const ReviewTreeAdapter = lazy(() => + import("./review-tree-adapter").then((module) => ({ default: module.ReviewTreeAdapter })), +); + +export function ReviewTreePane({ + sessionId, + workspaceName, + workspacePath, + tree, + files, + onSelectFile, +}: { + sessionId: string; + workspaceName: string; + workspacePath: string; + tree: WorkspaceTreeQuery; + files: ReadonlyArray; + onSelectFile: (path: string) => void; +}) { + const entries = useMemo( + () => (tree.data === undefined ? [] : unionDeletedReviewEntries(tree.data.entries, files)), + [files, tree.data], + ); + const gitStatus = useMemo(() => reviewGitStatusEntries(files), [files]); + + return ( +
+
+ + {workspaceName} + +
+ + {tree.isError && tree.data !== undefined ? ( +
+ + + Refresh failed; showing the previous file tree. + +
+ ) : null} + + {tree.isPending ? ( +
+ +
+ ) : tree.data === undefined ? ( + + + + + +
+ Unable to load files + {treeErrorMessage(tree.error)} +
+
+
+ ) : entries.length === 0 ? ( + + + + + +
+ No files + This workspace contains no visible files. +
+
+
+ ) : ( + + +
+ } + > + + + )} + + ); +} + +function treeErrorMessage(error: Error | null): string { + if (error === null) return "The workspace file tree could not be loaded."; + if (!(error instanceof ORPCError)) return error.message; + switch (error.code) { + case "NOT_DIRECTORY": + return "The project workspace is no longer a directory."; + case "PATH_ESCAPE": + return "The project workspace path is invalid."; + case "READ_FAILED": + return "The workspace may have moved, been deleted, or become unreadable."; + default: + return error.message; + } +} diff --git a/apps/app/src/features/review/review-tree.test.ts b/apps/app/src/features/review/review-tree.test.ts new file mode 100644 index 000000000..bb8be48a0 --- /dev/null +++ b/apps/app/src/features/review/review-tree.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + getReviewFileTree, + isOpenableTreeEntry, + symlinkDescription, + syncReviewFileTree, + toPierrePath, + unionDeletedReviewEntries, +} from "./review-tree"; + +describe("review file tree", () => { + it("converts directory paths to Pierre directory identifiers", () => { + expect(toPierrePath({ path: "src", type: "directory" })).toBe("src/"); + expect(toPierrePath({ path: "src/index.ts", type: "file" })).toBe("src/index.ts"); + }); + + it("adds deleted review paths that are gone from the workspace tree", () => { + const entries = unionDeletedReviewEntries( + [ + { path: "src", type: "directory" }, + { path: "src/keep.ts", type: "file" }, + ], + [ + { path: "src/gone.ts", status: "deleted" }, + { path: "legacy/old.ts", status: "deleted" }, + { path: "src/keep.ts", status: "modified" }, + ], + ); + expect(entries).toEqual([ + { path: "src", type: "directory" }, + { path: "src/keep.ts", type: "file" }, + { path: "src/gone.ts", type: "file" }, + { path: "legacy", type: "directory" }, + { path: "legacy/old.ts", type: "file" }, + ]); + }); + + it("preserves expanded directories across complete tree resets", () => { + const state = getReviewFileTree(`test-${crypto.randomUUID()}`); + syncReviewFileTree( + state, + [ + { path: "src", type: "directory" }, + { path: "src/index.ts", type: "file" }, + ], + [], + ); + + const src = state.model.getItem("src/"); + expect(src?.isDirectory()).toBe(true); + if (src === null || !src.isDirectory() || !("expand" in src)) { + throw new Error("src directory missing"); + } + src.expand(); + + syncReviewFileTree( + state, + [ + { path: "README.md", type: "file" }, + { path: "src", type: "directory" }, + { path: "src/index.ts", type: "file" }, + { path: "src/new.ts", type: "file" }, + ], + [{ path: "src/new.ts", status: "added" }], + ); + + const refreshedSrc = state.model.getItem("src/"); + expect(refreshedSrc?.isDirectory()).toBe(true); + if (refreshedSrc === null || !refreshedSrc.isDirectory() || !("isExpanded" in refreshedSrc)) { + throw new Error("refreshed src directory missing"); + } + expect(refreshedSrc.isExpanded()).toBe(true); + state.model.cleanUp(); + }); + + it("describes why non-file symlinks cannot be opened", () => { + expect( + symlinkDescription({ path: "dir-link", type: "symlink", symlinkTarget: "directory" }), + ).toContain("disabled"); + }); + + it("treats regular files as openable", () => { + expect(isOpenableTreeEntry({ path: "a.ts", type: "file" })).toBe(true); + }); +}); diff --git a/apps/app/src/features/review/review-tree.ts b/apps/app/src/features/review/review-tree.ts new file mode 100644 index 000000000..f9368e1db --- /dev/null +++ b/apps/app/src/features/review/review-tree.ts @@ -0,0 +1,138 @@ +import { FileTree, prepareFileTreeInput } from "@pierre/trees"; +import type { WorkspaceTreeEntry } from "@vibest/contract/fs"; +import type { GitReviewFile } from "@vibest/contract/git"; + +export interface ReviewFileTree { + readonly model: FileTree; + entries: ReadonlyArray; + entryByPath: ReadonlyMap; + directoryPaths: ReadonlySet; +} + +const reviewTrees = new Map(); + +export function toPierrePath(entry: WorkspaceTreeEntry): string { + return entry.type === "directory" ? `${entry.path}/` : entry.path; +} + +export function symlinkDescription(entry: WorkspaceTreeEntry): string | null { + if (entry.type !== "symlink") return null; + switch (entry.symlinkTarget) { + case "file": + return "Symbolic link to a file"; + case "directory": + return "Symbolic link to a directory; opening and expansion are disabled"; + case "outside": + return "Symbolic link outside the workspace; opening is disabled"; + case "broken": + return "Broken symbolic link; opening is disabled"; + case "other": + return "Symbolic link to an unsupported filesystem entry; opening is disabled"; + default: + return "Symbolic link with an unknown target"; + } +} + +function createReviewFileTree(): ReviewFileTree { + let state: ReviewFileTree; + const model = new FileTree({ + paths: [], + initialExpansion: "closed", + flattenEmptyDirectories: true, + stickyFolders: true, + renderRowDecoration: ({ item }) => { + const entry = state.entryByPath.get(item.path); + const title = entry === undefined ? null : symlinkDescription(entry); + return title === null ? null : { text: "↗", title }; + }, + }); + state = { + model, + entries: [], + entryByPath: new Map(), + directoryPaths: new Set(), + }; + return state; +} + +export function getReviewFileTree(sessionId: string): ReviewFileTree { + const existing = reviewTrees.get(sessionId); + if (existing !== undefined) return existing; + const created = createReviewFileTree(); + reviewTrees.set(sessionId, created); + return created; +} + +export function unionDeletedReviewEntries( + entries: ReadonlyArray, + files: ReadonlyArray, +): ReadonlyArray { + const existing = new Set(entries.map((entry) => entry.path)); + const extra: WorkspaceTreeEntry[] = []; + for (const file of files) { + if (file.status !== "deleted") continue; + const parts = file.path.split("/").filter((part) => part !== ""); + let prefix = ""; + for (const [index, part] of parts.entries()) { + prefix = prefix === "" ? part : `${prefix}/${part}`; + if (existing.has(prefix)) continue; + existing.add(prefix); + extra.push( + index === parts.length - 1 + ? { path: prefix, type: "file" } + : { path: prefix, type: "directory" }, + ); + } + } + return extra.length === 0 ? entries : [...entries, ...extra]; +} + +export function syncReviewFileTree( + state: ReviewFileTree, + entries: ReadonlyArray, + gitStatus: ReadonlyArray<{ path: string; status: "added" | "deleted" | "modified" | "renamed" }>, +): void { + if (state.entries !== entries) { + const expandedPaths: string[] = []; + for (const directoryPath of state.directoryPaths) { + const item = state.model.getItem(directoryPath); + if (item !== null && item.isDirectory() && "isExpanded" in item && item.isExpanded()) { + expandedPaths.push(directoryPath); + } + } + + const paths: string[] = []; + const nextDirectoryPaths = new Set(); + const entryByPath = new Map(); + for (const entry of entries) { + const pierrePath = toPierrePath(entry); + paths.push(pierrePath); + if (entry.type === "directory") nextDirectoryPaths.add(pierrePath); + entryByPath.set(entry.path, entry); + } + const preparedInput = prepareFileTreeInput(paths); + state.entries = entries; + state.entryByPath = entryByPath; + state.directoryPaths = nextDirectoryPaths; + state.model.resetPaths({ + preparedInput, + initialExpandedPaths: expandedPaths.filter((path) => nextDirectoryPaths.has(path)), + }); + } + state.model.setGitStatus(gitStatus); +} + +export function isOpenableTreeEntry(entry: WorkspaceTreeEntry | undefined): boolean { + return entry?.type === "file" || (entry?.type === "symlink" && entry.symlinkTarget === "file"); +} + +if (typeof window !== "undefined") { + window.addEventListener( + "beforeunload", + () => { + for (const state of reviewTrees.values()) state.model.cleanUp(); + reviewTrees.clear(); + }, + { once: true }, + ); +} diff --git a/apps/app/src/features/review/review-workspace-layout.tsx b/apps/app/src/features/review/review-workspace-layout.tsx new file mode 100644 index 000000000..b310fe9a0 --- /dev/null +++ b/apps/app/src/features/review/review-workspace-layout.tsx @@ -0,0 +1,103 @@ +import { Button } from "@vibest/ui/components/button"; +import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "@vibest/ui/components/sheet"; +import { useIsMobile } from "@vibest/ui/hooks/use-media-query"; +import { FilesIcon } from "lucide-react"; +import { type ReactNode, useId, useLayoutEffect, useRef, useState } from "react"; +import { Group, Panel, Separator } from "react-resizable-panels"; + +const MIN_SPLIT_WIDTH = 24 * 16 + 6; + +export function ReviewWorkspaceLayout({ + toolbar, + preview, + files, + filesLabel, +}: { + toolbar: ReactNode; + preview: ReactNode; + files: ReactNode; + filesLabel: string; +}) { + const isMobile = useIsMobile(); + const [isNarrow, setIsNarrow] = useState(false); + const [filesOpen, setFilesOpen] = useState(false); + const containerRef = useRef(null); + const drawerId = useId(); + + useLayoutEffect(() => { + const container = containerRef.current; + if (container === null) return; + + const updateWidth = (width: number): void => { + setIsNarrow(width < MIN_SPLIT_WIDTH); + }; + updateWidth(container.getBoundingClientRect().width); + + const observer = new ResizeObserver(([entry]) => { + if (entry !== undefined) updateWidth(entry.contentRect.width); + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + const useDrawer = isMobile || isNarrow; + + return ( +
+
+ {toolbar} + {useDrawer ? ( + + ) : null} +
+ {useDrawer ? ( + <> + {preview} + + + + Project files + +
{files}
+
+
+ + ) : ( + + + {preview} + + + + {files} + + + )} +
+ ); +} diff --git a/apps/app/src/features/review/use-git-branch.ts b/apps/app/src/features/review/use-git-branch.ts new file mode 100644 index 000000000..d372d55fb --- /dev/null +++ b/apps/app/src/features/review/use-git-branch.ts @@ -0,0 +1,15 @@ +import { skipToken, useQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; + +export function useGitBranch(cwd: string | undefined) { + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + return useQuery({ + ...orpcQueryUtils.git.branch.queryOptions({ + input: cwd === undefined ? skipToken : { cwd }, + }), + refetchOnWindowFocus: "always", + staleTime: Infinity, + }); +} + +export type GitBranchQuery = ReturnType; diff --git a/apps/app/src/features/review/use-git-diffs.ts b/apps/app/src/features/review/use-git-diffs.ts new file mode 100644 index 000000000..72cc552b0 --- /dev/null +++ b/apps/app/src/features/review/use-git-diffs.ts @@ -0,0 +1,31 @@ +import { skipToken, useQueries } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; +import type { GitReviewFile, GitReviewMode } from "@vibest/contract/git"; + +export function useGitDiffs( + cwd: string | undefined, + files: ReadonlyArray, + mode: GitReviewMode, + other: string | undefined, +) { + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + return useQueries({ + queries: files.map((file) => ({ + ...orpcQueryUtils.git.diff.queryOptions({ + input: + cwd === undefined + ? skipToken + : { + cwd, + path: file.path, + mode, + ...(mode === "branch" && other !== undefined ? { other } : {}), + }, + }), + refetchOnWindowFocus: "always" as const, + staleTime: Infinity, + })), + }); +} + +export type GitDiffsQuery = ReturnType; diff --git a/apps/app/src/features/review/use-git-review.ts b/apps/app/src/features/review/use-git-review.ts new file mode 100644 index 000000000..cceaed0b4 --- /dev/null +++ b/apps/app/src/features/review/use-git-review.ts @@ -0,0 +1,28 @@ +import { skipToken, useQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; +import type { GitReviewMode } from "@vibest/contract/git"; + +export function useGitReview( + cwd: string | undefined, + mode: GitReviewMode, + other: string | undefined, +) { + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + return useQuery({ + ...orpcQueryUtils.git.review.queryOptions({ + input: + cwd === undefined + ? skipToken + : { + cwd, + mode, + ...(mode === "branch" && other !== undefined ? { other } : {}), + }, + }), + enabled: cwd !== undefined && (mode !== "branch" || other !== undefined), + refetchOnWindowFocus: "always", + staleTime: Infinity, + }); +} + +export type GitReviewQuery = ReturnType; diff --git a/apps/app/src/features/review/use-session-workspace.ts b/apps/app/src/features/review/use-session-workspace.ts new file mode 100644 index 000000000..0afe86da8 --- /dev/null +++ b/apps/app/src/features/review/use-session-workspace.ts @@ -0,0 +1,23 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useMatch, useRouteContext } from "@tanstack/react-router"; +import type { Project } from "@vibest/contract"; +import { useCallback } from "react"; + +/** Resolve the active session's project to its workspace path without coupling this feature to projects UI. */ +export function useSessionWorkspace(): UseQueryResult { + const projectId = useMatch({ + from: "/session/$sessionId", + shouldThrow: false, + select: (match) => match.loaderData?.projectId, + }); + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + return useQuery({ + ...orpcQueryUtils.project.list.queryOptions(), + staleTime: Infinity, + // The select closes over projectId, so memoise it to preserve query result stability. + select: useCallback( + (projects: ReadonlyArray) => projects.find((project) => project.id === projectId), + [projectId], + ), + }); +} diff --git a/apps/app/src/features/review/use-workspace-tree.ts b/apps/app/src/features/review/use-workspace-tree.ts new file mode 100644 index 000000000..ab19b347a --- /dev/null +++ b/apps/app/src/features/review/use-workspace-tree.ts @@ -0,0 +1,15 @@ +import { skipToken, useQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; + +export function useWorkspaceTree(cwd: string | undefined) { + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + return useQuery({ + ...orpcQueryUtils.fs.readTree.queryOptions({ + input: cwd === undefined ? skipToken : { cwd }, + }), + refetchOnWindowFocus: "always", + staleTime: Infinity, + }); +} + +export type WorkspaceTreeQuery = ReturnType; diff --git a/apps/app/src/routes/__root.tsx b/apps/app/src/routes/__root.tsx index 4e2743109..585948172 100644 --- a/apps/app/src/routes/__root.tsx +++ b/apps/app/src/routes/__root.tsx @@ -17,7 +17,6 @@ import { import { AppSidebar } from "@/components/layout/app-sidebar"; import { CardPanel } from "@/components/layout/card-panel"; import { browserPanel } from "@/components/layout/content-panel/panels/browser-panel"; -import { diffPanel } from "@/components/layout/content-panel/panels/diff-panel"; import { ContentPanelSessionProvider } from "@/components/layout/content-panel/react/session-provider"; import { contentPanel } from "@/content-panel"; import { filePanel } from "@/features/files/file-panel"; @@ -25,6 +24,7 @@ import { filesPanel } from "@/features/files/files-panel"; import { useProjectSessionTitle } from "@/features/projects/use-project-sessions"; import { useProject } from "@/features/projects/use-projects"; import { useSessionListSync } from "@/features/projects/use-session-list-sync"; +import { reviewPanel } from "@/features/review/review-panel"; import { terminalPanel } from "@/features/terminal/terminal-panel"; import type { AppClients } from "@/lib/orpc"; import { sameSessionRef } from "@/lib/session-ref"; @@ -36,7 +36,7 @@ export interface RouterAppContext { queryClient: QueryClient; } -contentPanel.registerAll([filesPanel, filePanel, terminalPanel, diffPanel, browserPanel]); +contentPanel.registerAll([filesPanel, filePanel, reviewPanel, terminalPanel, browserPanel]); export const Route = createRootRouteWithContext()({ // Fetch the harness list once, right after the client connects and before diff --git a/packages/contract/src/git.ts b/packages/contract/src/git.ts index da5436d1e..d0854bc85 100644 --- a/packages/contract/src/git.ts +++ b/packages/contract/src/git.ts @@ -5,12 +5,137 @@ import { toStandardSchema } from "./domain"; const CwdInput = Schema.Struct({ cwd: Schema.String }); -/** Current HEAD name when the workspace is a git work tree; otherwise null. */ +const pathData = toStandardSchema(Schema.Struct({ path: Schema.String })); +const pathEscapeData = toStandardSchema(Schema.Struct({ cwd: Schema.String, path: Schema.String })); +const cwdData = toStandardSchema(Schema.Struct({ cwd: Schema.String })); +const refData = toStandardSchema(Schema.Struct({ ref: Schema.String })); + +export const GitStatusFileSchema = Schema.Struct({ + path: Schema.String, + index: Schema.String, + worktree: Schema.String, + oldPath: Schema.optionalKey(Schema.String), +}); +export type GitStatusFile = typeof GitStatusFileSchema.Type; + +export const GitStatusSchema = Schema.Struct({ + branch: Schema.Union([Schema.String, Schema.Null]), + files: Schema.Array(GitStatusFileSchema), +}); +export type GitStatus = typeof GitStatusSchema.Type; + export const GitBranchSchema = Schema.Struct({ current: Schema.Union([Schema.String, Schema.Null]), + /** Preferred compare target: `origin/main` when `origin/HEAD` exists, else local `main`. */ + defaultBranch: Schema.Union([Schema.String, Schema.Null]), + /** Local heads and remote-tracking refs (`main`, `origin/main`). */ + branches: Schema.Array(Schema.String), + /** Remote-tracking refs only (`origin/main`). Local names may contain `/`. */ + remotes: Schema.Array(Schema.String), }); export type GitBranch = typeof GitBranchSchema.Type; +export const GitReviewModeSchema = Schema.Literals(["uncommitted", "committed", "branch"]); +export type GitReviewMode = typeof GitReviewModeSchema.Type; + +export const GitReviewQuerySchema = Schema.Struct({ + cwd: Schema.String, + mode: Schema.optionalKey(GitReviewModeSchema), + other: Schema.optionalKey(Schema.String), +}); +export type GitReviewQuery = typeof GitReviewQuerySchema.Type; + +export const GitDiffQuerySchema = Schema.Struct({ + cwd: Schema.String, + path: Schema.String, + mode: Schema.optionalKey(GitReviewModeSchema), + other: Schema.optionalKey(Schema.String), +}); +export type GitDiffQuery = typeof GitDiffQuerySchema.Type; + +export const GitReviewFileStatusSchema = Schema.Literals([ + "modified", + "added", + "deleted", + "renamed", + "copied", +]); +export type GitReviewFileStatus = typeof GitReviewFileStatusSchema.Type; + +export const GitReviewFileSchema = Schema.Struct({ + path: Schema.String, + status: GitReviewFileStatusSchema, + oldPath: Schema.optionalKey(Schema.String), +}); +export type GitReviewFile = typeof GitReviewFileSchema.Type; + +/** + * A change set for the Review panel. `mode` chooses uncommitted vs HEAD, + * committed three-dot vs the default branch, or three-dot vs `other` + * (a local or remote-tracking ref). + */ +export const GitReviewSchema = Schema.Struct({ + mode: GitReviewModeSchema, + other: Schema.Union([Schema.String, Schema.Null]), + branch: Schema.Union([Schema.String, Schema.Null]), + base: Schema.String, + baseBranch: Schema.Union([Schema.String, Schema.Null]), + files: Schema.Array(GitReviewFileSchema), +}); +export type GitReview = typeof GitReviewSchema.Type; + +export const GitFileDiffSchema = Schema.Struct({ + path: Schema.String, + status: GitReviewFileStatusSchema, + oldPath: Schema.optionalKey(Schema.String), + oldContents: Schema.Union([Schema.String, Schema.Null]), + newContents: Schema.Union([Schema.String, Schema.Null]), + binary: Schema.Boolean, +}); +export type GitFileDiff = typeof GitFileDiffSchema.Type; + +const cwdErrors = { + PATH_ESCAPE: { data: pathEscapeData }, + NOT_DIRECTORY: { data: pathData }, + NOT_REPOSITORY: { data: cwdData }, + GIT_FAILED: { data: cwdData }, +}; + +const reviewErrors = { + ...cwdErrors, + REF_NOT_FOUND: { data: refData }, +}; + +const diffErrors = { + ...reviewErrors, + NOT_FOUND: { data: pathData }, + BINARY_FILE: { data: pathData }, + FILE_TOO_LARGE: { + data: toStandardSchema( + Schema.Struct({ path: Schema.String, size: Schema.Number, limit: Schema.Number }), + ), + }, +}; + +/** + * Read-only git. Callers pass `cwd`; the server confines paths to that + * workspace and never writes. `review` / `diff` take an explicit compare mode. + */ export const gitContract = { - branch: oc.input(toStandardSchema(CwdInput)).output(toStandardSchema(GitBranchSchema)), + status: oc + .input(toStandardSchema(CwdInput)) + .errors(cwdErrors) + .output(toStandardSchema(GitStatusSchema)), + branch: oc + .input(toStandardSchema(CwdInput)) + .errors(cwdErrors) + .output(toStandardSchema(GitBranchSchema)), + review: oc + .input(toStandardSchema(GitReviewQuerySchema)) + .errors(reviewErrors) + .output(toStandardSchema(GitReviewSchema)), + diff: oc + .input(toStandardSchema(GitDiffQuerySchema)) + .errors(diffErrors) + .output(toStandardSchema(GitFileDiffSchema)), }; diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 19392378b..0ff46e866 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -17,9 +17,20 @@ export class StoreWriteError extends Data.TaggedError("StoreWriteError")<{ }> {} export class GitError extends Data.TaggedError("GitError")<{ + readonly cwd: string; readonly cause: unknown; }> {} +/** `cwd` is not inside a Git work tree. */ +export class GitNotRepository extends Data.TaggedError("GitNotRepository")<{ + readonly cwd: string; +}> {} + +/** `other` is not a local head or remote-tracking ref in this repository. */ +export class GitRefNotFound extends Data.TaggedError("GitRefNotFound")<{ + readonly ref: string; +}> {} + export class SessionNotFound extends Data.TaggedError("SessionNotFound")<{ readonly projectId: string; readonly sessionId: string; diff --git a/packages/server/src/git/index.ts b/packages/server/src/git/index.ts index 850a40e56..bd565d869 100644 --- a/packages/server/src/git/index.ts +++ b/packages/server/src/git/index.ts @@ -1,2 +1,2 @@ -export type { BranchSummary, StatusResult } from "simple-git"; +export { parseNameStatus, parseNulPaths } from "./name-status"; export { GitService, GitServiceLayer } from "./service"; diff --git a/packages/server/src/git/name-status.ts b/packages/server/src/git/name-status.ts new file mode 100644 index 000000000..b008560d6 --- /dev/null +++ b/packages/server/src/git/name-status.ts @@ -0,0 +1,47 @@ +import type { GitReviewFile, GitReviewFileStatus } from "@vibest/contract/git"; + +const statusFromLetter = (letter: string): GitReviewFileStatus => { + if (letter === "A") return "added"; + if (letter === "D") return "deleted"; + if (letter === "R") return "renamed"; + if (letter === "C") return "copied"; + return "modified"; +}; + +/** + * Parse `git diff --name-status -z --find-renames` output. Rename/copy + * records are `STATUS\0old\0new\0`; everything else is `STATUS\0path\0`. + */ +export function parseNameStatus(raw: string): GitReviewFile[] { + if (raw === "") return []; + const parts = raw.split("\0"); + const files: GitReviewFile[] = []; + let index = 0; + while (index < parts.length) { + const code = parts[index]; + if (code === undefined || code === "") { + index += 1; + continue; + } + const letter = code[0] ?? ""; + if (letter === "R" || letter === "C") { + const oldPath = parts[index + 1]; + const nextPath = parts[index + 2]; + if (oldPath !== undefined && oldPath !== "" && nextPath !== undefined && nextPath !== "") { + files.push({ path: nextPath, status: statusFromLetter(letter), oldPath }); + } + index += 3; + continue; + } + const nextPath = parts[index + 1]; + if (nextPath !== undefined && nextPath !== "") { + files.push({ path: nextPath, status: statusFromLetter(letter) }); + } + index += 2; + } + return files; +} + +export function parseNulPaths(raw: string): string[] { + return raw.split("\0").filter((entry) => entry !== ""); +} diff --git a/packages/server/src/git/service.ts b/packages/server/src/git/service.ts index ece9153fa..c3b94edad 100644 --- a/packages/server/src/git/service.ts +++ b/packages/server/src/git/service.ts @@ -1,32 +1,475 @@ -import { Context, Effect, Layer } from "effect"; -import { type BranchSummary, simpleGit, type StatusResult } from "simple-git"; +import path from "node:path"; -import { GitError } from "../errors"; +import type { + GitBranch, + GitDiffQuery, + GitFileDiff, + GitReview, + GitReviewFile, + GitReviewMode, + GitReviewQuery, + GitStatus, + GitStatusFile, +} from "@vibest/contract/git"; +import { Context, Effect, FileSystem, Layer } from "effect"; +import { simpleGit } from "simple-git"; + +import { + GitError, + GitNotRepository, + GitRefNotFound, + WorkspaceBinaryFile, + WorkspaceFileNotFound, + WorkspaceFileTooLarge, + WorkspaceNotDirectory, + WorkspaceNotFile, + WorkspacePathEscape, + WorkspaceReadError, +} from "../errors"; +import { FileSystemService } from "../fs"; +import { parseNameStatus, parseNulPaths } from "./name-status"; + +const MAX_FILE_BYTES = 2 * 1024 * 1024; +const NUL_BYTE = 0; +const BINARY_MAGIC_PREFIXES: ReadonlyArray> = [ + [0x25, 0x50, 0x44, 0x46, 0x2d], + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + [0xff, 0xd8, 0xff], + [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], + [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], + [0x50, 0x4b, 0x03, 0x04], + [0x50, 0x4b, 0x05, 0x06], + [0x1f, 0x8b], + [0x7f, 0x45, 0x4c, 0x46], +]; +const DEFAULT_BRANCH_NAMES = ["main", "master", "trunk"] as const; + +const contains = (parent: string, child: string): boolean => { + const relative = path.relative(parent, child); + return ( + relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) + ); +}; + +const toPosixPath = (value: string): string => value.split(path.sep).join("/"); + +const hasBinaryMagicPrefix = (bytes: Uint8Array): boolean => + BINARY_MAGIC_PREFIXES.some( + (prefix) => + bytes.byteLength >= prefix.length && prefix.every((byte, index) => bytes[index] === byte), + ); + +const isNotRepositoryMessage = (cause: unknown): boolean => { + const message = cause instanceof Error ? cause.message : String(cause); + return /not a git repository/i.test(message); +}; + +const decodeText = ( + bytes: Uint8Array, + relativePath: string, +): Effect.Effect => { + if (bytes.includes(NUL_BYTE) || hasBinaryMagicPrefix(bytes)) { + return Effect.fail(new WorkspaceBinaryFile({ path: relativePath })); + } + try { + return Effect.succeed(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + return Effect.fail(new WorkspaceBinaryFile({ path: relativePath })); + } +}; + +/** Reject anything that is not a listed ref name — no `../`, flags, or rev magic. */ +const isUnsafeRef = (ref: string): boolean => + ref === "" || + ref.startsWith("-") || + ref.includes("..") || + ref.includes("\\") || + ref.includes("\0") || + ref.includes(":") || + ref.includes("@{") || + /\s/.test(ref); + +type GitFailure = + | WorkspacePathEscape + | WorkspaceNotDirectory + | WorkspaceReadError + | GitNotRepository + | GitError; + +type GitReviewFailure = GitFailure | GitRefNotFound; + +type GitDiffFailure = + | GitReviewFailure + | WorkspaceFileNotFound + | WorkspaceNotFile + | WorkspaceBinaryFile + | WorkspaceFileTooLarge; + +type ComparePlan = { + readonly mode: GitReviewMode; + readonly other: string | null; + readonly base: string; + readonly baseBranch: string | null; + readonly head: string | null; + readonly includeUntracked: boolean; +}; /** - * `git` module — read-only, delegating to the `git` CLI via simple-git. Returns - * simple-git's own result types (`StatusResult`, `BranchSummary`) rather than - * re-modelling them. Only `status`/`branch` are exposed for now (design §4.5 / - * §8). + * Read-only `git` module. Workspace confinement matches `FileSystemService`: + * `cwd` must be an absolute directory, and every path git reports is rewritten + * relative to that directory (files outside it are dropped). */ export class GitService extends Context.Service< GitService, { - readonly status: (dir: string) => Effect.Effect; - readonly branch: (dir: string) => Effect.Effect; + readonly status: (cwd: string) => Effect.Effect; + readonly branch: (cwd: string) => Effect.Effect; + readonly review: (query: GitReviewQuery) => Effect.Effect; + readonly diff: (query: GitDiffQuery) => Effect.Effect; } >()("GitService") {} -export const GitServiceLayer: Layer.Layer = Layer.sync(GitService, () => ({ - status: (dir) => - Effect.tryPromise({ - try: () => simpleGit(dir).status(), - catch: (cause) => new GitError({ cause }), - }), - - branch: (dir) => - Effect.tryPromise({ - try: () => simpleGit(dir).branch(), - catch: (cause) => new GitError({ cause }), - }), -})); +export const GitServiceLayer: Layer.Layer< + GitService, + never, + FileSystem.FileSystem | FileSystemService +> = Layer.effect( + GitService, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspace = yield* FileSystemService; + + const readError = (relativePath: string) => (cause: unknown) => + new WorkspaceReadError({ path: relativePath, cause }); + + const resolveRoot = (cwd: string) => + Effect.gen(function* () { + if (!path.isAbsolute(cwd)) { + return yield* new WorkspacePathEscape({ cwd, path: "." }); + } + const realRoot = yield* fs.realPath(cwd).pipe(Effect.mapError(readError("."))); + const info = yield* fs.stat(realRoot).pipe(Effect.mapError(readError("."))); + if (info.type !== "Directory") { + return yield* new WorkspaceNotDirectory({ path: "." }); + } + return realRoot; + }); + + const gitError = (cwd: string) => (cause: unknown) => + isNotRepositoryMessage(cause) ? new GitNotRepository({ cwd }) : new GitError({ cwd, cause }); + + const raw = (cwd: string, args: readonly string[]) => + Effect.tryPromise({ + try: () => simpleGit(cwd).raw([...args]), + catch: gitError(cwd), + }); + + const resolveRepoRoot = (cwd: string) => + raw(cwd, ["rev-parse", "--show-toplevel"]).pipe( + Effect.map((value) => value.trim()), + Effect.flatMap((toplevel) => + toplevel === "" + ? Effect.fail(new GitNotRepository({ cwd })) + : Effect.succeed(path.resolve(toplevel)), + ), + ); + + const toWorkspacePath = (cwd: string, repoRoot: string, gitPath: string): string | null => { + if (path.isAbsolute(gitPath) || gitPath.split(/[\\/]/).includes("..")) return null; + const absolute = path.resolve(repoRoot, gitPath); + if (!contains(cwd, absolute)) return null; + return toPosixPath(path.relative(cwd, absolute)) || gitPath; + }; + + const relocate = (cwd: string, repoRoot: string, file: GitReviewFile): GitReviewFile | null => { + const nextPath = toWorkspacePath(cwd, repoRoot, file.path); + if (nextPath === null) return null; + if (file.oldPath === undefined) return { ...file, path: nextPath }; + const oldPath = toWorkspacePath(cwd, repoRoot, file.oldPath); + if (oldPath === null) return { path: nextPath, status: file.status }; + return { path: nextPath, status: file.status, oldPath }; + }; + + const parseRefNames = (output: string): string[] => { + const names: string[] = []; + for (const line of output.split("\n")) { + const ref = line.trim(); + if (ref.startsWith("refs/heads/")) { + names.push(ref.slice("refs/heads/".length)); + } else if (ref.startsWith("refs/remotes/")) { + names.push(ref.slice("refs/remotes/".length)); + } + } + return names; + }; + + const listRefs = (cwd: string) => + raw(cwd, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes"]).pipe( + Effect.map((output) => { + const local: string[] = []; + const remotes: string[] = []; + for (const line of output.split("\n")) { + const ref = line.trim(); + if (ref.startsWith("refs/heads/")) { + local.push(ref.slice("refs/heads/".length)); + } else if (ref.startsWith("refs/remotes/")) { + remotes.push(ref.slice("refs/remotes/".length)); + } + } + return { local, remotes, all: [...local, ...remotes] }; + }), + ); + + const resolvePreferredCompareRef = (cwd: string) => + Effect.gen(function* () { + const remoteHead = yield* raw(cwd, [ + "symbolic-ref", + "--quiet", + "refs/remotes/origin/HEAD", + ]).pipe( + Effect.map((value) => value.trim()), + Effect.catch(() => Effect.succeed("")), + ); + if (remoteHead.startsWith("refs/remotes/")) { + return remoteHead.slice("refs/remotes/".length); + } + const local = yield* raw(cwd, ["for-each-ref", "--format=%(refname)", "refs/heads"]); + const names = new Set(parseRefNames(local)); + for (const name of DEFAULT_BRANCH_NAMES) { + if (names.has(name)) return name; + } + return null; + }); + + const mergeBase = (cwd: string, other: string) => + raw(cwd, ["merge-base", "HEAD", other]).pipe( + Effect.map((value) => value.trim()), + Effect.flatMap((sha) => + sha === "" + ? Effect.fail(new GitError({ cwd, cause: `empty merge-base with ${other}` })) + : Effect.succeed(sha), + ), + ); + + const resolveCompare = ( + cwd: string, + query: { readonly mode?: GitReviewMode; readonly other?: string }, + ): Effect.Effect => + Effect.gen(function* () { + const mode = query.mode ?? "uncommitted"; + if (mode === "uncommitted") { + return { + mode, + other: null, + base: "HEAD", + baseBranch: null, + head: null, + includeUntracked: true, + }; + } + + if (mode === "committed") { + const defaultRef = yield* resolvePreferredCompareRef(cwd); + if (defaultRef === null) { + return yield* new GitError({ cwd, cause: "no default branch to compare" }); + } + const base = yield* mergeBase(cwd, defaultRef); + return { + mode, + other: null, + base, + baseBranch: defaultRef, + head: "HEAD", + includeUntracked: false, + }; + } + + const other = query.other; + if (other === undefined || isUnsafeRef(other)) { + return yield* new GitRefNotFound({ ref: other ?? "" }); + } + const refs = yield* listRefs(cwd); + if (!refs.all.includes(other)) { + return yield* new GitRefNotFound({ ref: other }); + } + const base = yield* mergeBase(cwd, other); + return { + mode, + other, + base, + baseBranch: other, + head: null, + includeUntracked: true, + }; + }); + + const currentBranch = (cwd: string) => + raw(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( + Effect.map((value) => { + const name = value.trim(); + return name === "" ? null : name; + }), + ); + + const reviewFiles = (cwd: string, repoRoot: string, plan: ComparePlan) => + Effect.gen(function* () { + const diffArgs = + plan.head === null + ? ["diff", "--name-status", "-z", "--find-renames", plan.base] + : ["diff", "--name-status", "-z", "--find-renames", plan.base, plan.head]; + const nameStatus = yield* raw(cwd, diffArgs); + const tracked = parseNameStatus(nameStatus) + .map((file) => relocate(cwd, repoRoot, file)) + .filter((file): file is GitReviewFile => file !== null); + const files = [...tracked]; + const seen = new Set(tracked.map((file) => file.path)); + if (plan.includeUntracked) { + const untrackedRaw = yield* raw(cwd, [ + "ls-files", + "-z", + "--others", + "--exclude-standard", + ]); + for (const gitPath of parseNulPaths(untrackedRaw)) { + const nextPath = toWorkspacePath(cwd, repoRoot, gitPath); + if (nextPath === null || seen.has(nextPath)) continue; + seen.add(nextPath); + files.push({ path: nextPath, status: "added" }); + } + } + files.sort((left, right) => + left.path.localeCompare(right.path, undefined, { numeric: true, sensitivity: "base" }), + ); + return files; + }); + + const readWorktreeText = (cwd: string, relativePath: string) => + workspace.readFileString(cwd, relativePath); + + const toBlobPath = (cwd: string, repoRoot: string, relativePath: string): string => + toPosixPath(path.relative(repoRoot, path.resolve(cwd, relativePath))); + + const readBlobText = (cwd: string, treeish: string, blobPath: string) => + Effect.gen(function* () { + const sizeRaw = yield* raw(cwd, ["cat-file", "-s", `${treeish}:${blobPath}`]).pipe( + Effect.catch(() => Effect.succeed("")), + ); + if (sizeRaw.trim() === "") return null; + const size = Number(sizeRaw.trim()); + if (Number.isFinite(size) && size > MAX_FILE_BYTES) { + return yield* new WorkspaceFileTooLarge({ + path: blobPath, + size, + limit: MAX_FILE_BYTES, + }); + } + const text = yield* raw(cwd, ["cat-file", "-p", `${treeish}:${blobPath}`]); + const bytes = new TextEncoder().encode(text); + if (bytes.byteLength > MAX_FILE_BYTES) { + return yield* new WorkspaceFileTooLarge({ + path: blobPath, + size: bytes.byteLength, + limit: MAX_FILE_BYTES, + }); + } + return yield* decodeText(bytes, blobPath); + }); + + return { + status: (cwd) => + Effect.gen(function* () { + const realRoot = yield* resolveRoot(cwd); + const repoRoot = yield* resolveRepoRoot(realRoot); + const result = yield* Effect.tryPromise({ + try: () => simpleGit(realRoot).status(), + catch: gitError(realRoot), + }); + const files: GitStatusFile[] = []; + for (const file of result.files) { + const nextPath = toWorkspacePath(realRoot, repoRoot, file.path); + if (nextPath === null) continue; + const renameFrom = + "from" in file && typeof file.from === "string" ? file.from : undefined; + const relocatedFrom = + renameFrom === undefined + ? undefined + : toWorkspacePath(realRoot, repoRoot, renameFrom); + files.push({ + path: nextPath, + index: file.index, + worktree: file.working_dir, + ...(relocatedFrom === undefined || relocatedFrom === null + ? {} + : { oldPath: relocatedFrom }), + }); + } + return { branch: result.current ?? null, files }; + }), + + branch: (cwd) => + Effect.gen(function* () { + const realRoot = yield* resolveRoot(cwd); + const current = yield* currentBranch(realRoot); + const defaultBranch = yield* resolvePreferredCompareRef(realRoot); + const listed = yield* listRefs(realRoot); + return { + current, + defaultBranch, + branches: listed.all, + remotes: listed.remotes, + }; + }), + + review: (query) => + Effect.gen(function* () { + const realRoot = yield* resolveRoot(query.cwd); + const repoRoot = yield* resolveRepoRoot(realRoot); + const branch = yield* currentBranch(realRoot); + const plan = yield* resolveCompare(realRoot, query); + const files = yield* reviewFiles(realRoot, repoRoot, plan); + return { + mode: plan.mode, + other: plan.other, + branch, + base: plan.base, + baseBranch: plan.baseBranch, + files, + }; + }), + + diff: (query) => + Effect.gen(function* () { + const realRoot = yield* resolveRoot(query.cwd); + if (path.isAbsolute(query.path) || query.path.split(/[\\/]/).includes("..")) { + return yield* new WorkspacePathEscape({ cwd: realRoot, path: query.path }); + } + const repoRoot = yield* resolveRepoRoot(realRoot); + const plan = yield* resolveCompare(realRoot, query); + const files = yield* reviewFiles(realRoot, repoRoot, plan); + const file = files.find((entry) => entry.path === query.path); + if (file === undefined) { + return yield* new WorkspaceFileNotFound({ path: query.path }); + } + const oldBlobPath = toBlobPath(realRoot, repoRoot, file.oldPath ?? file.path); + const newBlobPath = toBlobPath(realRoot, repoRoot, file.path); + const oldContents = + file.status === "added" ? null : yield* readBlobText(realRoot, plan.base, oldBlobPath); + const newContents = + file.status === "deleted" + ? null + : plan.head === null + ? yield* readWorktreeText(realRoot, file.path) + : yield* readBlobText(realRoot, plan.head, newBlobPath); + return { + path: file.path, + status: file.status, + ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }), + oldContents, + newContents, + binary: false, + }; + }), + }; + }), +); diff --git a/packages/server/src/rpc/git.ts b/packages/server/src/rpc/git.ts index 23a22fe1b..b236be0f9 100644 --- a/packages/server/src/rpc/git.ts +++ b/packages/server/src/rpc/git.ts @@ -9,11 +9,77 @@ import type { RpcContext } from "./context"; const orpc = implement(gitContract).$context(); export const gitRouter = orpc.router({ - branch: orpc.branch.effect(function* ({ input }) { + status: orpc.status.effect(function* ({ input, errors }) { + const git = yield* GitService; + return yield* git.status(input.cwd).pipe( + Effect.catchTags({ + WorkspacePathEscape: (error) => + Effect.fail(errors.PATH_ESCAPE({ data: { cwd: error.cwd, path: error.path } })), + WorkspaceNotDirectory: (error) => + Effect.fail(errors.NOT_DIRECTORY({ data: { path: error.path } })), + WorkspaceReadError: () => Effect.fail(errors.GIT_FAILED({ data: { cwd: input.cwd } })), + GitNotRepository: (error) => + Effect.fail(errors.NOT_REPOSITORY({ data: { cwd: error.cwd } })), + GitError: (error) => Effect.fail(errors.GIT_FAILED({ data: { cwd: error.cwd } })), + }), + ); + }), + branch: orpc.branch.effect(function* ({ input, errors }) { const git = yield* GitService; return yield* git.branch(input.cwd).pipe( - Effect.map((summary) => ({ current: summary.current ?? null })), - Effect.catchTag("GitError", () => Effect.succeed({ current: null })), + Effect.catchTags({ + WorkspacePathEscape: (error) => + Effect.fail(errors.PATH_ESCAPE({ data: { cwd: error.cwd, path: error.path } })), + WorkspaceNotDirectory: (error) => + Effect.fail(errors.NOT_DIRECTORY({ data: { path: error.path } })), + WorkspaceReadError: () => Effect.fail(errors.GIT_FAILED({ data: { cwd: input.cwd } })), + GitNotRepository: (error) => + Effect.fail(errors.NOT_REPOSITORY({ data: { cwd: error.cwd } })), + GitError: (error) => Effect.fail(errors.GIT_FAILED({ data: { cwd: error.cwd } })), + }), + ); + }), + review: orpc.review.effect(function* ({ input, errors }) { + const git = yield* GitService; + return yield* git.review(input).pipe( + Effect.catchTags({ + WorkspacePathEscape: (error) => + Effect.fail(errors.PATH_ESCAPE({ data: { cwd: error.cwd, path: error.path } })), + WorkspaceNotDirectory: (error) => + Effect.fail(errors.NOT_DIRECTORY({ data: { path: error.path } })), + WorkspaceReadError: () => Effect.fail(errors.GIT_FAILED({ data: { cwd: input.cwd } })), + GitNotRepository: (error) => + Effect.fail(errors.NOT_REPOSITORY({ data: { cwd: error.cwd } })), + GitError: (error) => Effect.fail(errors.GIT_FAILED({ data: { cwd: error.cwd } })), + GitRefNotFound: (error) => Effect.fail(errors.REF_NOT_FOUND({ data: { ref: error.ref } })), + }), + ); + }), + diff: orpc.diff.effect(function* ({ input, errors }) { + const git = yield* GitService; + return yield* git.diff(input).pipe( + Effect.catchTags({ + WorkspacePathEscape: (error) => + Effect.fail(errors.PATH_ESCAPE({ data: { cwd: error.cwd, path: error.path } })), + WorkspaceNotDirectory: (error) => + Effect.fail(errors.NOT_DIRECTORY({ data: { path: error.path } })), + WorkspaceReadError: () => Effect.fail(errors.GIT_FAILED({ data: { cwd: input.cwd } })), + GitNotRepository: (error) => + Effect.fail(errors.NOT_REPOSITORY({ data: { cwd: error.cwd } })), + GitError: (error) => Effect.fail(errors.GIT_FAILED({ data: { cwd: error.cwd } })), + GitRefNotFound: (error) => Effect.fail(errors.REF_NOT_FOUND({ data: { ref: error.ref } })), + WorkspaceFileNotFound: (error) => + Effect.fail(errors.NOT_FOUND({ data: { path: error.path } })), + WorkspaceNotFile: (error) => Effect.fail(errors.NOT_FOUND({ data: { path: error.path } })), + WorkspaceBinaryFile: (error) => + Effect.fail(errors.BINARY_FILE({ data: { path: error.path } })), + WorkspaceFileTooLarge: (error) => + Effect.fail( + errors.FILE_TOO_LARGE({ + data: { path: error.path, size: error.size, limit: error.limit }, + }), + ), + }), ); }), }); diff --git a/packages/server/src/rpc/runtime.ts b/packages/server/src/rpc/runtime.ts index 8c9bc2575..be5aa4087 100644 --- a/packages/server/src/rpc/runtime.ts +++ b/packages/server/src/rpc/runtime.ts @@ -173,7 +173,7 @@ export const AgentRuntimeLayer = Layer.mergeAll( HarnessListProvided, HarnessProbeProvided, FileSystemServiceLayer.pipe(Layer.provide(PlatformLayer)), - GitServiceLayer, + GitServiceLayer.pipe(Layer.provide(FileSystemServiceLayer), Layer.provide(PlatformLayer)), PtyServiceProvided, PlatformLayer, // For the HTTP request app: `HttpStaticServer` needs it to turn a file into a diff --git a/packages/server/test/daemon/launcher.test.ts b/packages/server/test/daemon/launcher.test.ts index 87a79d18c..3d3e40275 100644 --- a/packages/server/test/daemon/launcher.test.ts +++ b/packages/server/test/daemon/launcher.test.ts @@ -38,7 +38,8 @@ const resolve = ( // `excludeTestServices` because the launcher polls a real daemon's health on a // real clock: under the default TestClock its retry schedule never advances. -// The timeout covers a spawn + readiness handshake, not a unit assertion. +// `timeout` here is the hook budget (layer build / daemon teardown). The +// per-test spawn + readiness budget lives in `vitest.config.ts`. layer(NodeServices.layer, { excludeTestServices: true, timeout: "30 seconds" })( "resolveOrSpawnDaemon", (it) => { @@ -269,7 +270,18 @@ layer(NodeServices.layer, { excludeTestServices: true, timeout: "30 seconds" })( ); const stopFiber = yield* Effect.forkScoped(stopDaemon(daemonDir, legacyDaemonDir)); - yield* Effect.sleep("100 millis"); + // `stopDaemon` writes tombstones before waiting on the locks this test + // holds. Poll instead of a fixed sleep: under load the fiber may not + // have run within 100ms. + yield* Effect.sleep("10 millis").pipe( + Effect.repeat({ + while: () => + Effect.gen(function* () { + return !(yield* hasTombstone(legacyDaemonDir)) || !(yield* hasTombstone(daemonDir)); + }), + }), + Effect.timeout("5 seconds"), + ); assert.equal(yield* hasTombstone(legacyDaemonDir), true); assert.equal(yield* hasTombstone(daemonDir), true); // Simulate a compatible explicit launcher clearing the early stop diff --git a/packages/server/test/git.test.ts b/packages/server/test/git.test.ts index 71543108b..9ff0a8da5 100644 --- a/packages/server/test/git.test.ts +++ b/packages/server/test/git.test.ts @@ -2,18 +2,21 @@ import assert from "node:assert/strict"; import path from "node:path"; import { layer } from "@effect/vitest"; -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Layer } from "effect"; import { simpleGit } from "simple-git"; -import { GitService, GitServiceLayer } from "../src/index"; +import { FileSystemServiceLayer } from "../src/fs"; +import { GitService, GitServiceLayer } from "../src/git"; import { NodePlatformLayer } from "./platform"; +const GitLayer = GitServiceLayer.pipe(Layer.provide(FileSystemServiceLayer)); + layer(NodePlatformLayer)("GitService", (it) => { /** A repo with one commit on `main`, removed when the test's scope closes. */ const repo = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "vibest-git-" }); - yield* fs.writeFileString(path.join(dir, "a.txt"), "hi"); + yield* fs.writeFileString(path.join(dir, "a.txt"), "hi\n"); yield* Effect.promise(async () => { const git = simpleGit(dir); await git.raw(["init", "-b", "main"]); @@ -25,6 +28,14 @@ layer(NodePlatformLayer)("GitService", (it) => { return dir; }); + const addRemoteMain = (dir: string) => + Effect.promise(async () => { + const git = simpleGit(dir); + const sha = (await git.revparse(["main"])).trim(); + await git.raw(["update-ref", "refs/remotes/origin/main", sha]); + await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); + }); + it.effect("reports working-tree status with untracked files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -33,18 +44,210 @@ layer(NodePlatformLayer)("GitService", (it) => { const git = yield* GitService; const status = yield* git.status(dir); - assert.equal(status.current, "main"); - assert.ok(status.not_added.includes("untracked.txt")); - }).pipe(Effect.provide(GitServiceLayer)), + assert.equal(status.branch, "main"); + assert.ok(status.files.some((file) => file.path === "untracked.txt")); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("lists branches and the local default branch", () => + Effect.gen(function* () { + const dir = yield* repo; + const git = yield* GitService; + const branch = yield* git.branch(dir); + assert.equal(branch.current, "main"); + assert.equal(branch.defaultBranch, "main"); + assert.ok(branch.branches.includes("main")); + assert.deepEqual(branch.remotes, []); + }).pipe(Effect.provide(GitLayer)), ); - it.effect("lists branches", () => + it.effect("lists local and remote-tracking refs without fetching", () => Effect.gen(function* () { const dir = yield* repo; + yield* addRemoteMain(dir); const git = yield* GitService; const branch = yield* git.branch(dir); assert.equal(branch.current, "main"); - assert.ok(branch.all.includes("main")); - }).pipe(Effect.provide(GitServiceLayer)), + assert.equal(branch.defaultBranch, "origin/main"); + assert.ok(branch.branches.includes("main")); + assert.ok(branch.branches.includes("origin/main")); + assert.ok(branch.branches.includes("origin/HEAD")); + assert.ok(branch.remotes.includes("origin/main")); + assert.ok(branch.remotes.includes("origin/HEAD")); + assert.ok(!branch.remotes.includes("main")); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("defaults review to uncommitted work against HEAD", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* repo; + yield* fs.writeFileString(path.join(dir, "a.txt"), "hello\n"); + yield* fs.writeFileString(path.join(dir, "added.txt"), "new\n"); + + const git = yield* GitService; + const review = yield* git.review({ cwd: dir }); + assert.equal(review.mode, "uncommitted"); + assert.equal(review.other, null); + assert.equal(review.branch, "main"); + assert.equal(review.base, "HEAD"); + assert.equal(review.baseBranch, null); + assert.deepEqual(Array.from(review.files.map((file) => file.path)).toSorted(), [ + "a.txt", + "added.txt", + ]); + + const modified = yield* git.diff({ cwd: dir, path: "a.txt" }); + assert.equal(modified.status, "modified"); + assert.equal(modified.oldContents, "hi\n"); + assert.equal(modified.newContents, "hello\n"); + + const added = yield* git.diff({ cwd: dir, path: "added.txt" }); + assert.equal(added.status, "added"); + assert.equal(added.oldContents, null); + assert.equal(added.newContents, "new\n"); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("committed mode diffs HEAD against merge-base and ignores the worktree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* repo; + yield* Effect.promise(async () => { + const git = simpleGit(dir); + await git.checkoutLocalBranch("feature"); + }); + yield* fs.writeFileString(path.join(dir, "feature.txt"), "branch\n"); + yield* Effect.promise(async () => { + const git = simpleGit(dir); + await git.add("feature.txt"); + await git.commit("feature work"); + }); + yield* fs.writeFileString(path.join(dir, "wip.txt"), "uncommitted\n"); + + const git = yield* GitService; + const review = yield* git.review({ cwd: dir, mode: "committed" }); + assert.equal(review.mode, "committed"); + assert.equal(review.branch, "feature"); + assert.equal(review.baseBranch, "main"); + assert.notEqual(review.base, "HEAD"); + assert.deepEqual(Array.from(review.files.map((file) => file.path)).toSorted(), [ + "feature.txt", + ]); + + const diff = yield* git.diff({ cwd: dir, mode: "committed", path: "feature.txt" }); + assert.equal(diff.oldContents, null); + assert.equal(diff.newContents, "branch\n"); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("branch mode includes uncommitted files against a local or remote ref", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* repo; + yield* Effect.promise(async () => { + const git = simpleGit(dir); + await git.checkoutLocalBranch("feature"); + }); + yield* fs.writeFileString(path.join(dir, "feature.txt"), "branch\n"); + yield* Effect.promise(async () => { + const git = simpleGit(dir); + await git.add("feature.txt"); + await git.commit("feature work"); + await git.checkout("main"); + }); + yield* fs.writeFileString(path.join(dir, "a.txt"), "main-line\n"); + yield* fs.writeFileString(path.join(dir, "extra.txt"), "only on main\n"); + yield* Effect.promise(async () => { + const git = simpleGit(dir); + await git.add(["a.txt", "extra.txt"]); + await git.commit("main moved forward"); + const sha = (await git.revparse(["main"])).trim(); + await git.raw(["update-ref", "refs/remotes/origin/main", sha]); + await git.checkout("feature"); + }); + yield* fs.writeFileString(path.join(dir, "wip.txt"), "uncommitted\n"); + + const git = yield* GitService; + const uncommitted = yield* git.review({ cwd: dir }); + assert.equal(uncommitted.mode, "uncommitted"); + assert.deepEqual(Array.from(uncommitted.files.map((file) => file.path)).toSorted(), [ + "wip.txt", + ]); + + const vsMain = yield* git.review({ cwd: dir, mode: "branch", other: "main" }); + assert.equal(vsMain.mode, "branch"); + assert.equal(vsMain.other, "main"); + assert.equal(vsMain.baseBranch, "main"); + assert.deepEqual(Array.from(vsMain.files.map((file) => file.path)).toSorted(), [ + "feature.txt", + "wip.txt", + ]); + + const vsOrigin = yield* git.review({ cwd: dir, mode: "branch", other: "origin/main" }); + assert.equal(vsOrigin.other, "origin/main"); + assert.deepEqual(Array.from(vsOrigin.files.map((file) => file.path)).toSorted(), [ + "feature.txt", + "wip.txt", + ]); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("rejects an unknown or missing compare ref", () => + Effect.gen(function* () { + const dir = yield* repo; + const git = yield* GitService; + + const missingOther = yield* git.review({ cwd: dir, mode: "branch" }).pipe(Effect.flip); + assert.equal(missingOther._tag, "GitRefNotFound"); + + const unknown = yield* git + .review({ cwd: dir, mode: "branch", other: "no-such-branch" }) + .pipe(Effect.flip); + assert.equal(unknown._tag, "GitRefNotFound"); + if (unknown._tag === "GitRefNotFound") assert.equal(unknown.ref, "no-such-branch"); + + const unsafe = yield* git + .review({ cwd: dir, mode: "branch", other: "../main" }) + .pipe(Effect.flip); + assert.equal(unsafe._tag, "GitRefNotFound"); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("diffs a deleted file against the review base", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* repo; + yield* fs.remove(path.join(dir, "a.txt")); + + const git = yield* GitService; + const diff = yield* git.diff({ cwd: dir, path: "a.txt" }); + assert.equal(diff.status, "deleted"); + assert.equal(diff.oldContents, "hi\n"); + assert.equal(diff.newContents, null); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("rejects a relative cwd and a non-repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "vibest-not-git-" }); + const git = yield* GitService; + + const relative = yield* git.status("relative/workspace").pipe(Effect.flip); + assert.equal(relative._tag, "WorkspacePathEscape"); + + const missing = yield* git.review({ cwd: dir }).pipe(Effect.flip); + assert.equal(missing._tag, "GitNotRepository"); + }).pipe(Effect.provide(GitLayer)), + ); + + it.effect("rejects a path that is not in the review set", () => + Effect.gen(function* () { + const dir = yield* repo; + const git = yield* GitService; + const missing = yield* git.diff({ cwd: dir, path: "nope.ts" }).pipe(Effect.flip); + assert.equal(missing._tag, "WorkspaceFileNotFound"); + }).pipe(Effect.provide(GitLayer)), ); }); diff --git a/packages/server/test/name-status.test.ts b/packages/server/test/name-status.test.ts new file mode 100644 index 000000000..e3e2de22d --- /dev/null +++ b/packages/server/test/name-status.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { parseNameStatus, parseNulPaths } from "../src/git/name-status"; + +describe("parseNameStatus", () => { + it("parses modified, added, and deleted records", () => { + expect(parseNameStatus("M\0src/a.ts\0A\0src/b.ts\0D\0src/c.ts\0")).toEqual([ + { path: "src/a.ts", status: "modified" }, + { path: "src/b.ts", status: "added" }, + { path: "src/c.ts", status: "deleted" }, + ]); + }); + + it("parses rename and copy records with the old path", () => { + expect(parseNameStatus("R100\0old.ts\0new.ts\0C080\0src/a.ts\0src/a-copy.ts\0")).toEqual([ + { path: "new.ts", status: "renamed", oldPath: "old.ts" }, + { path: "src/a-copy.ts", status: "copied", oldPath: "src/a.ts" }, + ]); + }); + + it("treats type changes as modified", () => { + expect(parseNameStatus("T\0script\0")).toEqual([{ path: "script", status: "modified" }]); + }); + + it("returns an empty list for empty output", () => { + expect(parseNameStatus("")).toEqual([]); + }); +}); + +describe("parseNulPaths", () => { + it("splits untracked paths and drops empties", () => { + expect(parseNulPaths("notes.md\0tmp/a.ts\0")).toEqual(["notes.md", "tmp/a.ts"]); + }); +}); diff --git a/packages/server/test/rpc-git.test.ts b/packages/server/test/rpc-git.test.ts index 6fb74514f..c42ee3970 100644 --- a/packages/server/test/rpc-git.test.ts +++ b/packages/server/test/rpc-git.test.ts @@ -7,23 +7,86 @@ import { describe, expect, it } from "vitest"; import { makeRpcTestHarness } from "./rpc-harness"; +async function makeRepo(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-rpc-git-")); + fs.writeFileSync(path.join(dir, "a.txt"), "hi\n"); + const git = simpleGit(dir); + await git.raw(["init", "-b", "main"]); + await git.addConfig("user.email", "test@example.com"); + await git.addConfig("user.name", "Test"); + await git.add("."); + await git.commit("init"); + return dir; +} + describe("git router", () => { - it("returns the current branch for a work tree and null otherwise", async () => { + it("returns the current branch for a work tree", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-home-")); - const repo = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-git-")); - const bare = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-nogit-")); - fs.writeFileSync(path.join(repo, "a.txt"), "hi"); - const git = simpleGit(repo); - await git.raw(["init", "-b", "main"]); - await git.addConfig("user.email", "test@example.com"); - await git.addConfig("user.name", "Test"); - await git.add("."); - await git.commit("init"); + const cwd = await makeRepo(); + const harness = await makeRpcTestHarness(home); + try { + await expect(harness.client.git.branch({ cwd })).resolves.toMatchObject({ + current: "main", + defaultBranch: "main", + branches: ["main"], + remotes: [], + }); + } finally { + await harness.dispose(); + } + }); + it("reviews uncommitted changes and returns a file diff", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-home-")); + const cwd = await makeRepo(); + fs.writeFileSync(path.join(cwd, "a.txt"), "hello\n"); + const harness = await makeRpcTestHarness(home); + try { + const review = await harness.client.git.review({ cwd }); + expect(review.mode).toBe("uncommitted"); + expect(review.other).toBeNull(); + expect(review.branch).toBe("main"); + expect(review.base).toBe("HEAD"); + expect(review.baseBranch).toBeNull(); + expect(review.files).toEqual([{ path: "a.txt", status: "modified" }]); + + const diff = await harness.client.git.diff({ cwd, path: "a.txt" }); + expect(diff.oldContents).toBe("hi\n"); + expect(diff.newContents).toBe("hello\n"); + } finally { + await harness.dispose(); + } + }); + + it("maps a missing compare ref to REF_NOT_FOUND", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-home-")); + const cwd = await makeRepo(); + const harness = await makeRpcTestHarness(home); + try { + await expect( + harness.client.git.review({ cwd, mode: "branch", other: "nope" }), + ).rejects.toMatchObject({ + code: "REF_NOT_FOUND", + data: { ref: "nope" }, + }); + } finally { + await harness.dispose(); + } + }); + + it("maps a non-repository and a relative cwd to typed errors", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-home-")); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-not-git-")); const harness = await makeRpcTestHarness(home); try { - await expect(harness.client.git.branch({ cwd: repo })).resolves.toEqual({ current: "main" }); - await expect(harness.client.git.branch({ cwd: bare })).resolves.toEqual({ current: null }); + await expect(harness.client.git.review({ cwd: dir })).rejects.toMatchObject({ + code: "NOT_REPOSITORY", + data: { cwd: dir }, + }); + await expect(harness.client.git.status({ cwd: "relative/workspace" })).rejects.toMatchObject({ + code: "PATH_ESCAPE", + data: { cwd: "relative/workspace", path: "." }, + }); } finally { await harness.dispose(); } diff --git a/packages/server/test/rpc-harness.ts b/packages/server/test/rpc-harness.ts index 59988f00d..636f79579 100644 --- a/packages/server/test/rpc-harness.ts +++ b/packages/server/test/rpc-harness.ts @@ -75,7 +75,7 @@ export async function makeRpcTestHarness( listLayer, probeLayer, FileSystemServiceLayer.pipe(Layer.provide(NodePlatformLayer)), - GitServiceLayer, + GitServiceLayer.pipe(Layer.provide(FileSystemServiceLayer), Layer.provide(NodePlatformLayer)), ptyLayer, NodePlatformLayer, ), diff --git a/packages/server/test/rpc-session.test.ts b/packages/server/test/rpc-session.test.ts index 942f21ff9..8a611aa54 100644 --- a/packages/server/test/rpc-session.test.ts +++ b/packages/server/test/rpc-session.test.ts @@ -121,7 +121,7 @@ async function setup() { HarnessListLayer.pipe(Layer.provide(registryLayer), Layer.provide(NodeServices.layer)), HarnessProbeLayer.pipe(Layer.provide(registryLayer)), FileSystemServiceLayer.pipe(Layer.provide(NodeServices.layer)), - GitServiceLayer, + GitServiceLayer.pipe(Layer.provide(FileSystemServiceLayer), Layer.provide(NodeServices.layer)), ptyServiceLayer, NodeServices.layer, Observability.discard, diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 4e19108dc..c862ce8d0 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + // Spawn + readiness tests (daemon launcher, harness transports) share this + // runner with turbo's parallel build/typecheck. Vitest's 5s default is too + // tight under that load, and `@effect/vitest` `layer({ timeout })` only + // covers hooks — not `it.effect`. + testTimeout: 30_000, typecheck: { enabled: true, tsconfig: "./tsconfig.json", diff --git a/packages/vibest/tsdown.config.ts b/packages/vibest/tsdown.config.ts index 298cec069..f99107b77 100644 --- a/packages/vibest/tsdown.config.ts +++ b/packages/vibest/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ deps: { // The private server/harness/contract packages are compiled into the CLI. // Whitelist their bundled runtime dependencies so additions fail closed. + // `simple-git` (and its tree) is pulled in by GitService on the serve path. // Native addons (node-pty) stay a CLI production dep and are externalized. onlyBundle: [ "effect", @@ -13,6 +14,13 @@ export default defineConfig({ "@effect/platform-node", "@standardserver/shared", "@orpc/experimental-effect", + "simple-git", + /^@simple-git\//, + /^@kwsites\//, + "debug", + "ms", + "supports-color", + "has-flag", ], }, dts: false,