diff options
30 files changed, 232 insertions, 1200 deletions
diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index e9cbf868d..1416aec72 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,15 +1,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { - defocus, - createTestProject, - cleanupTestProject, - openSidebar, - sessionIDFromUrl, - waitDir, - waitSlug, -} from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c71e00f6f..13494b7ad 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: { input.sdk.vcs.get().then((x) => { const next = x.data ?? input.store.vcs input.setStore("vcs", next) - if (next) input.vcsCache.setStore("value", next) + if (next?.branch) input.vcsCache.setStore("value", next) }), input.sdk.permission.list().then((x) => { const grouped = groupBySession( diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 0e243e95a..b8eda0573 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch?: string } + const props = event.properties as { branch: string } if (input.store.vcs?.branch === props.branch) break - const next = { ...input.store.vcs, branch: props.branch } + const next = { branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c7e0c95ea..72caed40a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -533,8 +533,6 @@ export const dict = { "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", - "session.review.noUncommittedChanges": "No uncommitted changes yet", - "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 52ac7c5f3..cca14fd50 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -633,8 +633,7 @@ export default function Layout(props: ParentProps) { if (!expanded) continue const key = workspaceKey(directory) const project = projects.find( - (item) => - workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue @@ -1164,8 +1163,7 @@ export default function Layout(props: ParentProps) { const project = layout.projects .list() .find( - (item) => - workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), ) if (project) return project.worktree diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 209cff8a7..886ffd26a 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -31,13 +31,11 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => - (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) +const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) -export const latestRootSession = (stores: SessionStore[], now: number) => - stores.flatMap(roots).sort(sortSessions(now))[0] +export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions<T>( request: Record<string, T[] | undefined>, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 970bc73b7..6d2917008 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { batch, @@ -57,9 +57,6 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = [] -type ChangeMode = "git" | "branch" | "session" | "turn" -type VcsMode = "git" | "branch" - type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -418,16 +415,15 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasSessionReview = createMemo(() => sessionCount() > 0) - const canReview = createMemo(() => !!params.dir) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview: canReview, + hasReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -503,22 +499,11 @@ export default function Page() { const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, + changes: "session" as "session" | "turn", newSessionWorktree: "main", deferRender: false, }) - const [vcs, setVcs] = createStore({ - diff: { - git: [] as FileDiff[], - branch: [] as FileDiff[], - }, - ready: { - git: false, - branch: false, - }, - }) - const [followup, setFollowup] = createStore({ items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, sending: {} as Record<string, string | undefined>, @@ -546,40 +531,6 @@ export default function Page() { let refreshTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined - const vcsTask = new Map<VcsMode, Promise<void>>() - - const resetVcs = () => { - vcsTask.clear() - setVcs({ - diff: { git: [], branch: [] }, - ready: { git: false, branch: false }, - }) - } - - const loadVcs = (mode: VcsMode, force = false) => { - if (sync.project?.vcs !== "git") return Promise.resolve() - if (vcs.ready[mode] && !force) return Promise.resolve() - const current = vcsTask.get(mode) - if (current) return current - - const task = sdk.client.vcs - .diff({ mode }) - .then((result) => { - setVcs("diff", mode, result.data ?? []) - setVcs("ready", mode, true) - }) - .catch((error) => { - console.debug("[session-review] failed to load vcs diff", { mode, error }) - setVcs("diff", mode, []) - setVcs("ready", mode, true) - }) - .finally(() => { - vcsTask.delete(mode) - }) - - vcsTask.set(mode, task) - return task - } createComputed((prev) => { const open = desktopReviewOpen() @@ -595,43 +546,7 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) - const changesOptions = createMemo<ChangeMode[]>(() => { - const list: ChangeMode[] = [] - const git = sync.project?.vcs === "git" - if (git) list.push("git") - if ( - git && - sync.data.vcs?.branch && - sync.data.vcs?.default_branch && - sync.data.vcs.branch !== sync.data.vcs.default_branch - ) { - list.push("branch") - } - list.push("session", "turn") - return list - }) - const vcsMode = createMemo<VcsMode | undefined>(() => { - if (store.changes === "git" || store.changes === "branch") return store.changes - }) - const reviewDiffs = createMemo(() => { - if (store.changes === "git") return vcs.diff.git - if (store.changes === "branch") return vcs.diff.branch - if (store.changes === "session") return diffs() - return turnDiffs() - }) - const reviewCount = createMemo(() => { - if (store.changes === "git") return vcs.diff.git.length - if (store.changes === "branch") return vcs.diff.branch.length - if (store.changes === "session") return sessionCount() - return turnDiffs().length - }) - const hasReview = createMemo(() => reviewCount() > 0) - const reviewReady = createMemo(() => { - if (store.changes === "git") return vcs.ready.git - if (store.changes === "branch") return vcs.ready.branch - if (store.changes === "session") return !hasSessionReview() || diffsReady() - return true - }) + const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -700,10 +615,10 @@ export default function Page() { const diffsReady = createMemo(() => { const id = params.id if (!id) return true - if (!hasSessionReview()) return true + if (!hasReview()) return true return sync.data.session_diff[id] !== undefined }) - const sessionEmptyKey = createMemo(() => { + const reviewEmptyKey = createMemo(() => { const project = sync.project if (project && !project.vcs) return "session.review.noVcs" if (sync.data.config.snapshot === false) return "session.review.noSnapshot" @@ -826,7 +741,7 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("changes", "git") + setStore("changes", "session") setUi("pendingMessage", undefined) }, { defer: true }, @@ -835,16 +750,6 @@ export default function Page() { createEffect( on( - () => sdk.directory, - () => { - resetVcs() - }, - { defer: true }, - ), - ) - - createEffect( - on( () => params.dir, (dir) => { if (!dir) return @@ -965,40 +870,6 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const wantsReview = createMemo(() => - isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes", - ) - - createEffect(() => { - const list = changesOptions() - if (list.includes(store.changes)) return - const next = list[0] - if (!next) return - setStore("changes", next) - }) - - createEffect(() => { - const mode = vcsMode() - if (!mode) return - if (!wantsReview()) return - void loadVcs(mode) - }) - - createEffect( - on( - () => sync.data.session_status[params.id ?? ""]?.type, - (next, prev) => { - const mode = vcsMode() - if (!mode) return - if (!wantsReview()) return - if (next !== "idle" || prev === undefined || prev === "idle") return - void loadVcs(mode, true) - }, - { defer: true }, - ), - ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -1045,23 +916,21 @@ export default function Page() { loadFile: file.load, }) + const changesOptions = ["session", "turn"] as const + const changesOptionsList = [...changesOptions] + const changesTitle = () => { - if (!canReview()) { + if (!hasReview()) { return null } - const label = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - if (option === "session") return language.t("ui.sessionReview.title") - return language.t("ui.sessionReview.title.lastTurn") - } - return ( <Select - options={changesOptions()} + options={changesOptionsList} current={store.changes} - label={label} + label={(option) => + option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn") + } onSelect={(option) => option && setStore("changes", option)} variant="ghost" size="small" @@ -1070,34 +939,20 @@ export default function Page() { ) } - const empty = (text: string) => ( + const emptyTurn = () => ( <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6"> - <div class="text-14-regular text-text-weak max-w-56">{text}</div> + <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div> </div> ) - const reviewEmptyText = createMemo(() => { - if (store.changes === "git") return language.t("session.review.noUncommittedChanges") - if (store.changes === "branch") return language.t("session.review.noBranchChanges") - if (store.changes === "turn") return language.t("session.review.noChanges") - return language.t(sessionEmptyKey()) - }) - const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { - if (store.changes === "git" || store.changes === "branch") { - if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div> - return empty(reviewEmptyText()) - } - - if (store.changes === "turn") { - return empty(reviewEmptyText()) - } + if (store.changes === "turn") return emptyTurn() - if (hasSessionReview() && !diffsReady()) { + if (hasReview() && !diffsReady()) { return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div> } - if (sessionEmptyKey() === "session.review.noVcs") { + if (reviewEmptyKey() === "session.review.noVcs") { return ( <div class={input.emptyClass}> <div class="flex flex-col gap-3"> @@ -1117,7 +972,7 @@ export default function Page() { return ( <div class={input.emptyClass}> - <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div> + <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div> </div> ) } @@ -1165,18 +1020,6 @@ export default function Page() { </div> ) - const mobileReview = () => - reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-8", - header: "px-4", - container: "px-4", - }, - loadingClass: "px-4 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - }) - createEffect( on( activeFileTab, @@ -1233,7 +1076,7 @@ export default function Page() { const pending = tree.pendingDiff if (!pending) return if (!tree.reviewScroll) return - if (!reviewReady()) return + if (!diffsReady()) return const attempt = (count: number) => { if (tree.pendingDiff !== pending) return @@ -1810,7 +1653,7 @@ export default function Page() { <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <SessionHeader /> <div class="flex-1 min-h-0 flex flex-col md:flex-row"> - <Show when={!isDesktop() && canReview()}> + <Show when={!isDesktop() && !!params.id}> <Tabs value={store.mobileTab} class="h-auto"> <Tabs.List> <Tabs.Trigger @@ -1852,7 +1695,16 @@ export default function Page() { <Show when={lastUserMessage()}> <MessageTimeline mobileChanges={mobileChanges()} - mobileFallback={mobileReview()} + mobileFallback={reviewContent({ + diffStyle: "unified", + classes: { + root: "pb-8", + header: "px-4", + container: "px-4", + }, + loadingClass: "px-4 py-4 text-text-weak", + emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", + })} actions={actions} scroll={ui.scroll} onResumeScroll={resumeScroll} @@ -1884,9 +1736,7 @@ export default function Page() { </Show> </Match> <Match when={true}> - <Show when={mobileChanges()} fallback={<NewSessionView worktree={newSessionWorktree()} />}> - <div class="relative h-full overflow-hidden">{mobileReview()}</div> - </Show> + <NewSessionView worktree={newSessionWorktree()} /> </Match> </Switch> </div> @@ -1958,12 +1808,6 @@ export default function Page() { </div> <SessionSidePanel - canReview={canReview} - diffs={reviewDiffs} - diffsReady={reviewReady} - empty={reviewEmptyText} - hasReview={hasReview} - reviewCount={reviewCount} reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3ba619736..3b8b0c96b 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -8,7 +8,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import type { FileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -20,6 +19,7 @@ import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" @@ -27,12 +27,6 @@ import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" export function SessionSidePanel(props: { - canReview: () => boolean - diffs: () => FileDiff[] - diffsReady: () => boolean - empty: () => string - hasReview: () => boolean - reviewCount: () => number reviewPanel: () => JSX.Element activeDiff?: string focusReviewDiff: (path: string) => void @@ -40,11 +34,12 @@ export function SessionSidePanel(props: { size: Sizing }) { const layout = useLayout() + const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() const dialog = useDialog() - const { sessionKey, tabs, view } = useSessionLayout() + const { params, sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -59,7 +54,24 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) + + const reviewEmptyKey = createMemo(() => { + if (sync.project && !sync.project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.noChanges" + }) + + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -70,7 +82,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map<string, "add" | "del" | "mix">() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" @@ -124,7 +136,7 @@ export function SessionSidePanel(props: { pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview: props.canReview, + hasReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -229,12 +241,12 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > - <Show when={reviewTab() && props.canReview()}> + <Show when={reviewTab()}> <Tabs.Trigger value="review"> <div class="flex items-center gap-1.5"> <div>{language.t("session.tab.review")}</div> - <Show when={props.hasReview()}> - <div>{props.reviewCount()}</div> + <Show when={hasReview()}> + <div>{reviewCount()}</div> </Show> </div> </Tabs.Trigger> @@ -291,7 +303,7 @@ export function SessionSidePanel(props: { </Tabs.List> </div> - <Show when={reviewTab() && props.canReview()}> + <Show when={reviewTab()}> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> </Tabs.Content> @@ -365,10 +377,8 @@ export function SessionSidePanel(props: { > <Tabs.List> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {reviewCount()}{" "} + {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} </Tabs.Trigger> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> {language.t("session.files.all")} @@ -376,9 +386,9 @@ export function SessionSidePanel(props: { </Tabs.List> <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0"> <Switch> - <Match when={props.hasReview() || !props.diffsReady()}> + <Match when={hasReview()}> <Show - when={props.diffsReady()} + when={diffsReady()} fallback={ <div class="px-2 py-2 text-12-regular text-text-weak"> {language.t("common.loading")} @@ -397,7 +407,11 @@ export function SessionSidePanel(props: { /> </Show> </Match> - <Match when={true}>{empty(props.empty())}</Match> + <Match when={true}> + {empty( + language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()), + )} + </Match> </Switch> </Tabs.Content> <Tabs.Content value="all" class="bg-background-stronger px-3 py-0"> diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f45374359..1a2e777f5 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -56,7 +56,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (!id) return return sync.session.get(id) } - const hasReview = () => !!params.id + const hasReview = () => { + const id = params.id + if (!id) return false + return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0 + } const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 1d8542133..edd9d7561 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -30,7 +30,7 @@ import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" -import { Git } from "@/git" +import { git } from "@/util/git" type GitHubAuthor = { login: string @@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({ } // Get repo info - const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() + const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree }) + const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree }) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index a3a15afbc..8826fe343 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -2,7 +2,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Instance } from "@/project/instance" import { Process } from "@/util/process" -import { Git } from "@/git" +import { git } from "@/util/git" export const PrCommand = cmd({ command: "pr <number>", @@ -67,9 +67,9 @@ export const PrCommand = cmd({ const remoteName = forkOwner // Check if remote already exists - const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim() + const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: Instance.worktree, }) UI.println(`Added fork remote: ${remoteName}`) @@ -77,7 +77,7 @@ export const PrCommand = cmd({ // Set upstream to the fork so pushes go there const headRefName = prInfo.headRefName - await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { + await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { cwd: Instance.worktree, }) } diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 5ff1fc121..c05458d5d 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -40,7 +40,7 @@ function lookup(_key: string) { Layer.fresh(PermissionNext.layer), Layer.fresh(ProviderAuth.defaultLayer), Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.defaultLayer), + Layer.fresh(Vcs.layer), Layer.fresh(FileTime.layer).pipe(Layer.orDie), Layer.fresh(Format.layer), Layer.fresh(File.layer), diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 02209e7a0..f52203b22 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,7 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { AccountEffect } from "@/account/effect" import { AuthEffect } from "@/auth/effect" -import { GitEffect } from "@/git/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -10,7 +9,6 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( AccountEffect.defaultLayer, // - GitEffect.defaultLayer, TruncateEffect.defaultLayer, Instances.layer, ).pipe(Layer.provideMerge(AuthEffect.layer)), diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 79f8d9f7d..6e9b91727 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { Git } from "@/git" +import { git } from "@/util/git" import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" import fs from "fs" @@ -440,7 +440,7 @@ export namespace File { return yield* Effect.promise(async () => { const diffOutput = ( - await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { cwd: instance.directory, }) ).text() @@ -460,7 +460,7 @@ export namespace File { } const untrackedOutput = ( - await Git.run( + await git( [ "-c", "core.fsmonitor=false", @@ -493,7 +493,7 @@ export namespace File { } const deletedOutput = ( - await Git.run( + await git( [ "-c", "core.fsmonitor=false", @@ -584,17 +584,17 @@ export namespace File { if (instance.project.vcs === "git") { let diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) ).text() if (!diff.trim()) { diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory, }) ).text() } if (diff.trim()) { - const original = (await Git.run(["show", `HEAD:${file}`], { cwd: instance.directory })).text() + const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index a689e6385..16ab3c6d3 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -10,7 +10,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { Flag } from "@/flag/flag" import { Instance } from "@/project/instance" -import { Git } from "@/git" +import { git } from "@/util/git" import { lazy } from "@/util/lazy" import { Config } from "../config/config" import { FileIgnore } from "./ignore" @@ -117,7 +117,7 @@ export namespace FileWatcher { if (instance.project.vcs === "git") { const result = yield* Effect.promise(() => - Git.run(["rev-parse", "--git-dir"], { + git(["rev-parse", "--git-dir"], { cwd: instance.project.worktree, }), ) diff --git a/packages/opencode/src/git/effect.ts b/packages/opencode/src/git/effect.ts deleted file mode 100644 index feff72b44..000000000 --- a/packages/opencode/src/git/effect.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" -import { Effect, Layer, ServiceMap, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" - -export namespace GitEffect { - const cfg = [ - "--no-optional-locks", - "-c", - "core.autocrlf=false", - "-c", - "core.fsmonitor=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ] as const - - function out(result: { text(): string }) { - return result.text().trim() - } - - function split(text: string) { - return text.split("\0").filter(Boolean) - } - - export type Kind = "added" | "deleted" | "modified" - - export type Base = { - readonly name: string - readonly ref: string - } - - export type Item = { - readonly file: string - readonly code: string - readonly status: Kind - } - - export type Stat = { - readonly file: string - readonly additions: number - readonly deletions: number - } - - export interface Result { - readonly exitCode: number - readonly text: () => string - readonly stdout: Buffer - readonly stderr: Buffer - } - - export interface Options { - readonly cwd: string - readonly env?: Record<string, string> - } - - export interface Interface { - readonly run: (args: string[], opts: Options) => Effect.Effect<Result> - readonly text: (args: string[], opts: Options) => Effect.Effect<string> - readonly lines: (args: string[], opts: Options) => Effect.Effect<string[]> - readonly branch: (cwd: string) => Effect.Effect<string | undefined> - readonly prefix: (cwd: string) => Effect.Effect<string> - readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined> - readonly hasHead: (cwd: string) => Effect.Effect<boolean> - readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined> - readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string> - readonly status: (cwd: string) => Effect.Effect<Item[]> - readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]> - readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]> - } - - function kind(code: string | undefined): Kind { - if (code === "??") return "added" - if (code?.includes("U")) return "modified" - if (code?.includes("A") && !code.includes("D")) return "added" - if (code?.includes("D") && !code.includes("A")) return "deleted" - return "modified" - } - - function parseStatus(text: string) { - return split(text).flatMap((item) => { - const file = item.slice(3) - if (!file) return [] - const code = item.slice(0, 2) - return [{ file, code, status: kind(code) } satisfies Item] - }) - } - - function parseNames(text: string) { - const list = split(text) - const out: Item[] = [] - for (let i = 0; i < list.length; i += 2) { - const code = list[i] - const file = list[i + 1] - if (!code || !file) continue - out.push({ file, code, status: kind(code) }) - } - return out - } - - function parseStats(text: string) { - const out: Stat[] = [] - for (const item of split(text)) { - const a = item.indexOf("\t") - const b = item.indexOf("\t", a + 1) - if (a === -1 || b === -1) continue - const file = item.slice(b + 1) - if (!file) continue - const adds = item.slice(0, a) - const dels = item.slice(a + 1, b) - const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) - const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) - out.push({ - file, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - }) - } - return out - } - - export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fn("Git.run")( - function* (args: string[], opts: Options) { - const proc = ChildProcess.make("git", [...cfg, ...args], { - cwd: opts.cwd, - env: opts.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - return { - exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), - } satisfies Result - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(1), - text: () => "", - stdout: Buffer.alloc(0), - stderr: Buffer.from(String(err)), - }), - ), - ) - - const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { - return (yield* run(args, opts)).text() - }) - - const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { - return (yield* text(args, opts)) - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean) - }) - - const refs = Effect.fnUntraced(function* (cwd: string) { - return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) - }) - - const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { - const result = yield* run(["config", "init.defaultBranch"], { cwd }) - if (result.exitCode !== 0) return - const name = out(result) - if (!name || !list.includes(name)) return - const ref = yield* run(["rev-parse", "--verify", name], { cwd }) - if (ref.exitCode !== 0) return - return { name, ref: name } satisfies Base - }) - - const remoteHead = Effect.fnUntraced(function* (cwd: string, remote: string) { - const result = yield* run(["ls-remote", "--symref", remote, "HEAD"], { cwd }) - if (result.exitCode !== 0) return - for (const line of result.text().split("\n")) { - const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim()) - if (!match?.[1]) continue - return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base - } - }) - - const primary = Effect.fnUntraced(function* (cwd: string) { - const list = yield* lines(["remote"], { cwd }) - if (list.includes("origin")) return "origin" - if (list.length === 1) return list[0] - if (list.includes("upstream")) return "upstream" - return list[0] - }) - - const branch = Effect.fn("Git.branch")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) - if (result.exitCode !== 0) return "" - return out(result) - }) - - const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { - const remote = yield* primary(cwd) - if (remote) { - const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) - if (head.exitCode === 0) { - const ref = out(head).replace(/^refs\/remotes\//, "") - const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" - if (name) return { name, ref } satisfies Base - } - - const next = yield* remoteHead(cwd, remote) - if (next) return next - } - - const list = yield* refs(cwd) - const next = yield* configured(cwd, list) - if (next) return next - for (const name of ["main", "master"]) { - if (list.includes(name)) return { name, ref: name } satisfies Base - } - }) - - const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) - return result.exitCode === 0 - }) - - const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { - const result = yield* run(["merge-base", base, head], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { - const target = prefix ? `${prefix}${file}` : file - const result = yield* run(["show", `${ref}:${target}`], { cwd }) - if (result.exitCode !== 0) return "" - return result.text() - }) - - const status = Effect.fn("Git.status")(function* (cwd: string) { - return parseStatus( - yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { cwd }), - ) - }) - - const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { - return parseNames( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), - ) - }) - - const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { - return parseStats( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), - ) - }) - - return Service.of({ - run, - text, - lines, - branch, - prefix, - defaultBranch, - hasHead, - mergeBase, - show, - status, - diff, - stats, - }) - }), - ) - - const platformLayer = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), - ) - - export const defaultLayer = layer.pipe(Layer.provide(platformLayer)) -} diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts deleted file mode 100644 index 1b04353b1..000000000 --- a/packages/opencode/src/git/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Effect } from "effect" -import { runtime } from "@/effect/runtime" -import { GitEffect } from "./effect" - -function runPromise<A>(f: (service: GitEffect.Interface) => Effect.Effect<A>): Promise<A> { - return runtime.runPromise(GitEffect.Service.use(f)) -} - -export namespace Git { - export type Kind = GitEffect.Kind - export type Base = GitEffect.Base - export type Item = GitEffect.Item - export type Stat = GitEffect.Stat - export type Result = GitEffect.Result - export type Options = GitEffect.Options - - export function run(args: string[], opts: Options) { - return runPromise((git) => git.run(args, opts)) - } - - export function text(args: string[], opts: Options) { - return runPromise((git) => git.text(args, opts)) - } - - export function lines(args: string[], opts: Options) { - return runPromise((git) => git.lines(args, opts)) - } - - export function branch(cwd: string) { - return runPromise((git) => git.branch(cwd)) - } - - export function prefix(cwd: string) { - return runPromise((git) => git.prefix(cwd)) - } - - export function defaultBranch(cwd: string) { - return runPromise((git) => git.defaultBranch(cwd)) - } - - export function hasHead(cwd: string) { - return runPromise((git) => git.hasHead(cwd)) - } - - export function mergeBase(cwd: string, base: string, head?: string) { - return runPromise((git) => git.mergeBase(cwd, base, head)) - } - - export function show(cwd: string, ref: string, file: string, prefix?: string) { - return runPromise((git) => git.show(cwd, ref, file, prefix)) - } - - export function status(cwd: string) { - return runPromise((git) => git.status(cwd)) - } - - export function diff(cwd: string, ref: string) { - return runPromise((git) => git.diff(cwd, ref)) - } - - export function stats(cwd: string, ref: string) { - return runPromise((git) => git.stats(cwd, ref)) - } -} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f2df9982e..1cef41c85 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -11,7 +11,7 @@ import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { existsSync } from "fs" -import { Git } from "@/git" +import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" @@ -119,7 +119,7 @@ export namespace Project { } } - const worktree = await Git.run(["rev-parse", "--git-common-dir"], { + const worktree = await git(["rev-parse", "--git-common-dir"], { cwd: sandbox, }) .then(async (result) => { @@ -147,7 +147,7 @@ export namespace Project { // generate id from root commit if (!id) { - const roots = await Git.run(["rev-list", "--max-parents=0", "HEAD"], { + const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox, }) .then(async (result) => @@ -184,7 +184,7 @@ export namespace Project { } } - const top = await Git.run(["rev-parse", "--show-toplevel"], { + const top = await git(["rev-parse", "--show-toplevel"], { cwd: sandbox, }) .then(async (result) => gitpath(sandbox, await result.text())) @@ -349,7 +349,7 @@ export namespace Project { if (input.project.vcs === "git") return input.project if (!which("git")) throw new Error("Git is not installed") - const result = await Git.run(["init", "--quiet"], { + const result = await git(["init", "--quiet"], { cwd: input.directory, }) if (result.exitCode !== 0) { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 7358ec414..9e85571c4 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,122 +1,16 @@ -import { AppFileSystem } from "@/filesystem" import { Effect, Layer, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" -import { GitEffect } from "@/git/effect" -import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -import path from "path" +import { git } from "@/util/git" import { Instance } from "./instance" import z from "zod" -function count(text: string) { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) - -function stats(list: GitEffect.Stat[]) { - const out = new Map<string, { additions: number; deletions: number }>() - for (const item of list) { - out.set(item.file, { - additions: item.additions, - deletions: item.deletions, - }) - } - return out -} - -function merge(...lists: GitEffect.Item[][]) { - const out = new Map<string, GitEffect.Item>() - for (const list of lists) { - for (const item of list) { - if (!out.has(item.file)) out.set(item.file, item) - } - } - return [...out.values()] -} - -const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string | undefined, - list: GitEffect.Item[], - nums: Map<string, { additions: number; deletions: number }>, -) { - const base = ref ? yield* git.prefix(cwd) : "" - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = nums.get(item.file) - return { - file: item.file, - before, - after, - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies Snapshot.FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) -}) - -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string | undefined, -) { - if (!ref) { - return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - } - const [list, nums] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, stats(nums)) -}) - -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string, -) { - const [list, nums, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { - concurrency: 3, - }) - return yield* files( - fs, - git, - cwd, - ref, - merge( - list, - extra.filter((item) => item.code === "??"), - ), - stats(nums), - ) -}) - export namespace Vcs { const log = Log.create({ service: "vcs" }) - export const Mode = z.enum(["git", "branch"]) - export type Mode = z.infer<typeof Mode> - export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", @@ -128,8 +22,7 @@ export namespace Vcs { export const Info = z .object({ - branch: z.string().optional(), - default_branch: z.string().optional(), + branch: z.string(), }) .meta({ ref: "VcsInfo", @@ -138,8 +31,6 @@ export namespace Vcs { export interface Interface { readonly branch: () => Effect.Effect<string | undefined> - readonly defaultBranch: () => Effect.Effect<string | undefined> - readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]> } export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {} @@ -148,18 +39,20 @@ export namespace Vcs { Service, Effect.gen(function* () { const instance = yield* InstanceContext - const fs = yield* AppFileSystem.Service - const git = yield* GitEffect.Service - let current: string | undefined - let root: GitEffect.Base | undefined + let currentBranch: string | undefined if (instance.project.vcs === "git") { - const get = () => Effect.runPromise(git.branch(instance.directory)) - - ;[current, root] = yield* Effect.all([git.branch(instance.directory), git.defaultBranch(instance.directory)], { - concurrency: 2, - }) - log.info("initialized", { branch: current, default_branch: root?.name }) + const getCurrentBranch = async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: instance.project.worktree, + }) + if (result.exitCode !== 0) return undefined + const text = result.text().trim() + return text || undefined + } + + currentBranch = yield* Effect.promise(() => getCurrentBranch()) + log.info("initialized", { branch: currentBranch }) yield* Effect.acquireRelease( Effect.sync(() => @@ -167,11 +60,12 @@ export namespace Vcs { FileWatcher.Event.Updated, Instance.bind(async (evt) => { if (!evt.properties.file.endsWith("HEAD")) return - const next = await get() - if (next === current) return - log.info("branch changed", { from: current, to: next }) - current = next - Bus.publish(Event.BranchUpdated, { branch: next }) + const next = await getCurrentBranch() + if (next !== currentBranch) { + log.info("branch changed", { from: currentBranch, to: next }) + currentBranch = next + Bus.publish(Event.BranchUpdated, { branch: next }) + } }), ), ), @@ -181,30 +75,9 @@ export namespace Vcs { return Service.of({ branch: Effect.fn("Vcs.branch")(function* () { - return current - }), - defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { - return root?.name - }), - diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { - if (instance.project.vcs !== "git") return [] - if (mode === "git") { - const ok = yield* git.hasHead(instance.directory) - return yield* track(fs, git, instance.directory, ok ? "HEAD" : undefined) - } - - if (!root) return [] - if (current && current === root.name) return [] - const ref = yield* git.mergeBase(instance.directory, root.ref) - if (!ref) return [] - return yield* compare(fs, git, instance.directory, ref) + return currentBranch }), }) }), ) - - export const defaultLayer = layer.pipe( - Layer.provide(GitEffect.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b0872b557..c485654fd 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -41,7 +41,6 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" -import { Snapshot } from "@/snapshot" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -333,39 +332,10 @@ export namespace Server { }, }), async (c) => { - const [branch, default_branch] = await Promise.all([ - runPromiseInstance(Vcs.Service.use((s) => s.branch())), - runPromiseInstance(Vcs.Service.use((s) => s.defaultBranch())), - ]) - return c.json({ branch, default_branch }) - }, - ) - .get( - "/vcs/diff", - describeRoute({ - summary: "Get VCS diff", - description: "Retrieve the current git diff for the working tree or against the default branch.", - operationId: "vcs.diff", - responses: { - 200: { - description: "VCS diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - mode: Vcs.Mode, - }), - ), - async (c) => { - const mode = c.req.valid("query").mode - return c.json(await runPromiseInstance(Vcs.Service.use((s) => s.diff(mode)))) + const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) + return c.json({ + branch, + }) }, ) .get( diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 99067594d..a78607cdf 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -8,7 +8,7 @@ import { Lock } from "../util/lock" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import { Glob } from "../util/glob" -import { Git } from "@/git" +import { git } from "@/util/git" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -49,7 +49,7 @@ export namespace Storage { } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue - const result = await Git.run(["rev-list", "--max-parents=0", "--all"], { + const result = await git(["rev-list", "--max-parents=0", "--all"], { cwd: worktree, }) const [id] = result diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts new file mode 100644 index 000000000..731131357 --- /dev/null +++ b/packages/opencode/src/util/git.ts @@ -0,0 +1,35 @@ +import { Process } from "./process" + +export interface GitResult { + exitCode: number + text(): string + stdout: Buffer + stderr: Buffer +} + +/** + * Run a git command. + * + * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance + * issues in embedded/client environments. + */ +export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> { + return Process.run(["git", ...args], { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + nothrow: true, + }) + .then((result) => ({ + exitCode: result.code, + text: () => result.stdout.toString(), + stdout: result.stdout, + stderr: result.stderr, + })) + .catch((error) => ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(error instanceof Error ? error.message : String(error)), + })) +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index d1f52fff9..6ed0e4820 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -12,7 +12,7 @@ import type { ProjectID } from "../project/schema" import { fn } from "../util/fn" import { Log } from "../util/log" import { Process } from "../util/process" -import { Git } from "@/git" +import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -250,14 +250,14 @@ export namespace Worktree { } async function sweep(root: string) { - const first = await Git.run(["clean", "-ffdx"], { cwd: root }) + const first = await git(["clean", "-ffdx"], { cwd: root }) if (first.exitCode === 0) return first const entries = failed(first) if (!entries.length) return first await prune(root, entries) - return Git.run(["clean", "-ffdx"], { cwd: root }) + return git(["clean", "-ffdx"], { cwd: root }) } async function canonical(input: string) { @@ -276,7 +276,7 @@ export namespace Worktree { if (await exists(directory)) continue const ref = `refs/heads/${branch}` - const branchCheck = await Git.run(["show-ref", "--verify", "--quiet", ref], { + const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree, }) if (branchCheck.exitCode === 0) continue @@ -348,7 +348,7 @@ export namespace Worktree { } export async function createFromInfo(info: Info, startCommand?: string) { - const created = await Git.run(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { + const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: Instance.worktree, }) if (created.exitCode !== 0) { @@ -362,7 +362,7 @@ export namespace Worktree { return () => { const start = async () => { - const populated = await Git.run(["reset", "--hard"], { cwd: info.directory }) + const populated = await git(["reset", "--hard"], { cwd: info.directory }) if (populated.exitCode !== 0) { const message = errorText(populated) || "Failed to populate worktree" log.error("worktree checkout failed", { directory: info.directory, message }) @@ -479,10 +479,10 @@ export namespace Worktree { const stop = async (target: string) => { if (!(await exists(target))) return - await Git.run(["fsmonitor--daemon", "stop"], { cwd: target }) + await git(["fsmonitor--daemon", "stop"], { cwd: target }) } - const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -499,11 +499,11 @@ export namespace Worktree { } await stop(entry.path) - const removed = await Git.run(["worktree", "remove", "--force", entry.path], { + const removed = await git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree, }) if (removed.exitCode !== 0) { - const next = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (next.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(removed) || errorText(next) || "Failed to remove git worktree", @@ -520,7 +520,7 @@ export namespace Worktree { const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { - const deleted = await Git.run(["branch", "-D", branch], { cwd: Instance.worktree }) + const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree }) if (deleted.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" }) } @@ -540,7 +540,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } - const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -573,7 +573,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Worktree not found" }) } - const remoteList = await Git.run(["remote"], { cwd: Instance.worktree }) + const remoteList = await git(["remote"], { cwd: Instance.worktree }) if (remoteList.exitCode !== 0) { throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) } @@ -592,17 +592,17 @@ export namespace Worktree { : "" const remoteHead = remote - ? await Git.run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) + ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) : { exitCode: 1, stdout: undefined, stderr: undefined } const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" - const mainCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/main"], { + const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree, }) - const masterCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/master"], { + const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree, }) const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" @@ -613,7 +613,7 @@ export namespace Worktree { } if (remoteBranch) { - const fetch = await Git.run(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) + const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) if (fetch.exitCode !== 0) { throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) } @@ -625,7 +625,7 @@ export namespace Worktree { const worktreePath = entry.path - const resetToTarget = await Git.run(["reset", "--hard", target], { cwd: worktreePath }) + const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) if (resetToTarget.exitCode !== 0) { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } @@ -635,26 +635,26 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } - const update = await Git.run(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) + const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) if (update.exitCode !== 0) { throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) } - const subReset = await Git.run(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { + const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { cwd: worktreePath, }) if (subReset.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) } - const subClean = await Git.run(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { + const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { cwd: worktreePath, }) if (subClean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await Git.run(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) if (status.exitCode !== 0) { throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) } diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts deleted file mode 100644 index 3427d8eb5..000000000 --- a/packages/opencode/test/git/git.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { $ } from "bun" -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { ManagedRuntime } from "effect" -import { GitEffect } from "../../src/git/effect" -import { tmpdir } from "../fixture/fixture" - -const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" - -async function withGit<T>(fn: (rt: ManagedRuntime.ManagedRuntime<GitEffect.Service, never>) => Promise<T>) { - const rt = ManagedRuntime.make(GitEffect.defaultLayer) - try { - return await fn(rt) - } finally { - await rt.dispose() - } -} - -describe("Git", () => { - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.branch(tmp.path))) - expect(branch).toBeDefined() - expect(typeof branch).toBe("string") - }) - }) - - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.branch(tmp.path))) - expect(branch).toBeUndefined() - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.defaultBranch(tmp.path))) - expect(branch?.name).toBe("trunk") - expect(branch?.ref).toBe("trunk") - }) - }) - - test("status() handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - const file = weird - await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8") - - await withGit(async (rt) => { - const status = await rt.runPromise(GitEffect.Service.use((git) => git.status(tmp.path))) - expect(status).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "added", - }), - ]), - ) - }) - }) - - test("diff(), stats(), and mergeBase() parse tracked changes", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - const file = weird - await fs.writeFile(path.join(tmp.path, file), "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, file), "after\n", "utf-8") - - await withGit(async (rt) => { - const [base, diff, stats] = await Promise.all([ - rt.runPromise(GitEffect.Service.use((git) => git.mergeBase(tmp.path, "main"))), - rt.runPromise(GitEffect.Service.use((git) => git.diff(tmp.path, "HEAD"))), - rt.runPromise(GitEffect.Service.use((git) => git.stats(tmp.path, "HEAD"))), - ]) - - expect(base).toBeTruthy() - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "modified", - }), - ]), - ) - expect(stats).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - additions: 1, - deletions: 1, - }), - ]), - ) - }) - }) -}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e6588fb38..a71fe0528 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -10,48 +10,45 @@ import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) -const gitModule = await import("../../src/git") -const originalGit = gitModule.Git.run +const gitModule = await import("../../src/util/git") +const originalGit = gitModule.git type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" let mode: Mode = "none" -mock.module("../../src/git", () => ({ - Git: { - ...gitModule.Git, - run: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) - }, +mock.module("../../src/util/git", () => ({ + git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => { + const cmd = ["git", ...args].join(" ") + if ( + mode === "rev-list-fail" && + cmd.includes("git rev-list") && + cmd.includes("--max-parents=0") && + cmd.includes("HEAD") + ) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + return originalGit(args, opts) }, })) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index c0967993f..90f445ed7 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -23,7 +23,7 @@ function withVcs( ) { return withServices( directory, - Layer.merge(FileWatcher.layer, Vcs.defaultLayer), + Layer.merge(FileWatcher.layer, Vcs.layer), async (rt) => { await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(Vcs.Service.use(() => Effect.void)) @@ -34,15 +34,7 @@ function withVcs( ) } -function withVcsOnly( - directory: string, - body: (rt: ManagedRuntime.ManagedRuntime<Vcs.Service, never>) => Promise<void>, -) { - return withServices(directory, Vcs.defaultLayer, body) -} - type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } -const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ function nextBranchUpdate(directory: string, timeout = 10_000) { @@ -129,104 +121,3 @@ describeVcs("Vcs", () => { }) }) }) - -describe("Vcs diff", () => { - afterEach(() => Instance.disposeAll()) - - test("defaultBranch() falls back to main", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) - expect(branch).toBe("main") - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) - expect(branch).toBe("trunk") - }) - }) - - test("detects current branch from the active worktree", async () => { - await using tmp = await tmpdir({ git: true }) - await using wt = await tmpdir() - await $`git branch -M main`.cwd(tmp.path).quiet() - const dir = path.join(wt.path, "feature") - await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() - - await withVcsOnly(dir, async (rt) => { - const [branch, base] = await Promise.all([ - rt.runPromise(Vcs.Service.use((s) => s.branch())), - rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())), - ]) - expect(branch).toBe("feature/test") - expect(base).toBe("main") - }) - }) - - test("diff('git') returns uncommitted changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "file.txt", - status: "modified", - }), - ]), - ) - }) - }) - - test("diff('git') handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - const file = weird - await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8") - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "added", - }), - ]), - ) - }) - }) - - test("diff('branch') returns changes against default branch", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("branch"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "branch.txt", - status: "added", - }), - ]), - ) - }) - }) -}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6ec934e6b..aa759bb1e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -172,7 +172,6 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, - VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -3662,38 +3661,6 @@ export class Vcs extends HeyApiClient { ...params, }) } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff<ThrowOnError extends boolean = false>( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options<never, ThrowOnError>, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({ - url: "/vcs/diff", - ...options, - ...params, - }) - } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 942e1c3e1..41aa24817 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1889,8 +1889,7 @@ export type Path = { } export type VcsInfo = { - branch?: string - default_branch?: string + branch: string } export type Command = { @@ -4834,26 +4833,6 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array<FileDiff> -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - export type CommandListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 41819ef12..350395423 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6576,59 +6576,6 @@ ] } }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" - } - ] - } - }, "/command": { "get": { "operationId": "command.list", @@ -11981,11 +11928,9 @@ "properties": { "branch": { "type": "string" - }, - "default_branch": { - "type": "string" } - } + }, + "required": ["branch"] }, "Command": { "type": "object", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index d9f724fce..18823aeaa 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -1,7 +1,5 @@ export const dict: Record<string, string> = { "ui.sessionReview.title": "Session changes", - "ui.sessionReview.title.git": "Git changes", - "ui.sessionReview.title.branch": "Branch changes", "ui.sessionReview.title.lastTurn": "Last turn changes", "ui.sessionReview.diffStyle.unified": "Unified", "ui.sessionReview.diffStyle.split": "Split", |
