summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-03-19 11:15:07 -0500
committerGitHub <[email protected]>2026-03-19 11:15:07 -0500
commitaeece6166b7e728440f1a3c81aa7efcc32208f01 (patch)
treec5d2666b61e5fe79fc6739e10b873add67d39b02
parent0d7e62a532ba9f2163f872414c52a213c517936a (diff)
downloadopencode-aeece6166b7e728440f1a3c81aa7efcc32208f01.tar.gz
opencode-aeece6166b7e728440f1a3c81aa7efcc32208f01.zip
ignore: revert 3 commits that broke dev branch (#18260)
-rw-r--r--packages/app/e2e/projects/projects-switch.spec.ts10
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts2
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts4
-rw-r--r--packages/app/src/i18n/en.ts2
-rw-r--r--packages/app/src/pages/layout.tsx6
-rw-r--r--packages/app/src/pages/layout/helpers.ts6
-rw-r--r--packages/app/src/pages/session.tsx228
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx58
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/github.ts10
-rw-r--r--packages/opencode/src/cli/cmd/pr.ts8
-rw-r--r--packages/opencode/src/effect/instances.ts2
-rw-r--r--packages/opencode/src/effect/runtime.ts2
-rw-r--r--packages/opencode/src/file/index.ts14
-rw-r--r--packages/opencode/src/file/watcher.ts4
-rw-r--r--packages/opencode/src/git/effect.ts298
-rw-r--r--packages/opencode/src/git/index.ts64
-rw-r--r--packages/opencode/src/project/project.ts10
-rw-r--r--packages/opencode/src/project/vcs.ts169
-rw-r--r--packages/opencode/src/server/server.ts38
-rw-r--r--packages/opencode/src/storage/storage.ts4
-rw-r--r--packages/opencode/src/util/git.ts35
-rw-r--r--packages/opencode/src/worktree/index.ts44
-rw-r--r--packages/opencode/test/git/git.test.ts107
-rw-r--r--packages/opencode/test/project/project.test.ts73
-rw-r--r--packages/opencode/test/project/vcs.test.ts111
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts33
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts23
-rw-r--r--packages/sdk/openapi.json59
-rw-r--r--packages/ui/src/i18n/en.ts2
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",