summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-04-03 20:24:57 +0530
committerGitHub <[email protected]>2026-04-03 09:54:57 -0500
commit35350b1d25a56665cf065eba68929fc00617fdd2 (patch)
tree91bf53b5d87ff9532ebf0a779c4dbe7bc99148f3
parent263dcf75b548810a149f08ea5e32e0f6754128d5 (diff)
downloadopencode-35350b1d25a56665cf065eba68929fc00617fdd2.tar.gz
opencode-35350b1d25a56665cf065eba68929fc00617fdd2.zip
feat: restore git-backed review modes (#20845)
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts2
-rw-r--r--packages/app/src/context/global-sync/event-reducer.test.ts10
-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/session.tsx272
-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/file/index.ts14
-rw-r--r--packages/opencode/src/file/watcher.ts4
-rw-r--r--packages/opencode/src/git/index.ts303
-rw-r--r--packages/opencode/src/project/vcs.ts182
-rw-r--r--packages/opencode/src/server/instance.ts33
-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.ts51
-rw-r--r--packages/opencode/test/git/git.test.ts128
-rw-r--r--packages/opencode/test/project/vcs.test.ts132
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts33
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts21
-rw-r--r--packages/ui/src/components/session-review.tsx35
-rw-r--r--packages/ui/src/i18n/en.ts2
23 files changed, 1102 insertions, 247 deletions
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index cf104ad97..7edd5a1ce 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -248,7 +248,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?.branch) input.vcsCache.setStore("value", next)
+ if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
index cf2da135c..892129788 100644
--- a/packages/app/src/context/global-sync/event-reducer.test.ts
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
})
test("updates vcs branch in store and cache", () => {
- const [store, setStore] = createStore(baseState())
- const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
+ const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
+ const [cacheStore, setCacheStore] = createStore({
+ value: { branch: "main", default_branch: "main" } as State["vcs"],
+ })
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
},
})
- expect(store.vcs).toEqual({ branch: "feature/test" })
- expect(cacheStore.value).toEqual({ branch: "feature/test" })
+ expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
+ expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
})
test("routes disposal and lsp events to side-effect handlers", () => {
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index 5d8b7c4e3..4af636553 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -271,9 +271,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 = { branch: props.branch }
+ const next = { ...input.store.vcs, 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 39317b8d6..ace0efeb8 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -535,6 +535,8 @@ 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/session.tsx b/packages/app/src/pages/session.tsx
index ae895adbe..a81df9dd2 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
+import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
@@ -68,6 +68,9 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
+type ChangeMode = "git" | "branch" | "session" | "turn"
+type VcsMode = "git" | "branch"
+
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
@@ -427,15 +430,16 @@ 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 reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
- const hasReview = createMemo(() => reviewCount() > 0)
+ const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
+ const hasSessionReview = createMemo(() => sessionCount() > 0)
+ const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
- hasReview,
+ hasReview: canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -458,6 +462,12 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
+ const diffsReady = createMemo(() => {
+ const id = params.id
+ if (!id) return true
+ if (!hasSessionReview()) return true
+ return sync.data.session_diff[id] !== undefined
+ })
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -511,11 +521,22 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
- changes: "session" as "session" | "turn",
+ changes: "git" as ChangeMode,
newSessionWorktree: "main",
deferRender: false,
})
+ const [vcs, setVcs] = createStore({
+ diff: {
+ git: [] as FileDiff[],
+ branch: [] as FileDiff[],
+ },
+ ready: {
+ git: false,
+ branch: false,
+ },
+ })
+
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
@@ -549,6 +570,68 @@ export default function Page() {
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
+ const vcsTask = new Map<VcsMode, Promise<void>>()
+ const vcsRun = new Map<VcsMode, number>()
+
+ const bumpVcs = (mode: VcsMode) => {
+ const next = (vcsRun.get(mode) ?? 0) + 1
+ vcsRun.set(mode, next)
+ return next
+ }
+
+ const resetVcs = (mode?: VcsMode) => {
+ const list = mode ? [mode] : (["git", "branch"] as const)
+ list.forEach((item) => {
+ bumpVcs(item)
+ vcsTask.delete(item)
+ setVcs("diff", item, [])
+ setVcs("ready", item, false)
+ })
+ }
+
+ const loadVcs = (mode: VcsMode, force = false) => {
+ if (sync.project?.vcs !== "git") return Promise.resolve()
+ if (!force && vcs.ready[mode]) return Promise.resolve()
+
+ if (force) {
+ if (vcsTask.has(mode)) bumpVcs(mode)
+ vcsTask.delete(mode)
+ setVcs("ready", mode, false)
+ }
+
+ const current = vcsTask.get(mode)
+ if (current) return current
+
+ const run = bumpVcs(mode)
+
+ const task = sdk.client.vcs
+ .diff({ mode })
+ .then((result) => {
+ if (vcsRun.get(mode) !== run) return
+ setVcs("diff", mode, result.data ?? [])
+ setVcs("ready", mode, true)
+ })
+ .catch((error) => {
+ if (vcsRun.get(mode) !== run) return
+ console.debug("[session-review] failed to load vcs diff", { mode, error })
+ setVcs("diff", mode, [])
+ setVcs("ready", mode, true)
+ })
+ .finally(() => {
+ if (vcsTask.get(mode) === task) vcsTask.delete(mode)
+ })
+
+ vcsTask.set(mode, task)
+ return task
+ }
+
+ const refreshVcs = () => {
+ resetVcs()
+ const mode = untrack(vcsMode)
+ if (!mode) return
+ if (!untrack(wantsReview)) return
+ void loadVcs(mode, true)
+ }
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -564,7 +647,42 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
- const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
+ const changesOptions = createMemo<ChangeMode[]>(() => {
+ const list: ChangeMode[] = []
+ if (sync.project?.vcs === "git") list.push("git")
+ if (
+ sync.project?.vcs === "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 newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -630,13 +748,7 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
- 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(() => {
+ const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -790,7 +902,7 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
- setStore("changes", "session")
+ setStore("changes", "git")
setUi("pendingMessage", undefined)
},
{ defer: true },
@@ -799,6 +911,39 @@ export default function Page() {
createEffect(
on(
+ () => sdk.directory,
+ () => {
+ resetVcs()
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
+ (next, prev) => {
+ if (prev === undefined || same(next, prev)) return
+ refreshVcs()
+ },
+ { defer: true },
+ ),
+ )
+
+ const stopVcs = sdk.event.listen((evt) => {
+ if (evt.details.type !== "file.watcher.updated") return
+ const props =
+ typeof evt.details.properties === "object" && evt.details.properties
+ ? (evt.details.properties as Record<string, unknown>)
+ : undefined
+ const file = typeof props?.file === "string" ? props.file : undefined
+ if (!file || file.startsWith(".git/")) return
+ refreshVcs()
+ })
+ onCleanup(stopVcs)
+
+ createEffect(
+ on(
() => params.dir,
(dir) => {
if (!dir) return
@@ -919,6 +1064,40 @@ 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)
@@ -965,21 +1144,23 @@ export default function Page() {
loadFile: file.load,
})
- const changesOptions = ["session", "turn"] as const
- const changesOptionsList = [...changesOptions]
-
const changesTitle = () => {
- if (!hasReview()) {
+ if (!canReview()) {
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={changesOptionsList}
+ options={changesOptions()}
current={store.changes}
- label={(option) =>
- option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
- }
+ label={label}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -988,20 +1169,34 @@ export default function Page() {
)
}
- const emptyTurn = () => (
+ const empty = (text: string) => (
<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">{language.t("session.review.noChanges")}</div>
+ <div class="text-14-regular text-text-weak max-w-56">{text}</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 === "turn") return emptyTurn()
+ if (store.changes === "git" || store.changes === "branch") {
+ if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
+ return empty(reviewEmptyText())
+ }
- if (hasReview() && !diffsReady()) {
+ if (store.changes === "turn") {
+ return empty(reviewEmptyText())
+ }
+
+ if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
- if (reviewEmptyKey() === "session.review.noVcs") {
+ if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
@@ -1021,7 +1216,7 @@ export default function Page() {
return (
<div class={input.emptyClass}>
- <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
+ <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div>
)
}
@@ -1128,7 +1323,7 @@ export default function Page() {
const pending = tree.pendingDiff
if (!pending) return
if (!tree.reviewScroll) return
- if (!diffsReady()) return
+ if (!reviewReady()) return
const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
@@ -1169,10 +1364,7 @@ export default function Page() {
const id = params.id
if (!id) return
- const wants = isDesktop()
- ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
- : store.mobileTab === "changes"
- if (!wants) return
+ if (!wantsReview()) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
@@ -1181,13 +1373,7 @@ export default function Page() {
createEffect(
on(
- () =>
- [
- sessionKey(),
- isDesktop()
- ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
- : store.mobileTab === "changes",
- ] as const,
+ () => [sessionKey(), wantsReview()] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1867,6 +2053,12 @@ 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 c07942627..86f932ea2 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -8,6 +8,7 @@ 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"
@@ -18,7 +19,6 @@ 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"
@@ -26,6 +26,12 @@ 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
@@ -33,12 +39,11 @@ 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 { params, sessionKey, tabs, view } = useSessionLayout()
+ const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -53,24 +58,7 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
- 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 diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -81,7 +69,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
- for (const diff of diffs()) {
+ for (const diff of props.diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -135,7 +123,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
- hasReview,
+ hasReview: props.canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -240,12 +228,12 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
- <Show when={reviewTab()}>
+ <Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
- <Show when={hasReview()}>
- <div>{reviewCount()}</div>
+ <Show when={props.hasReview()}>
+ <div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -304,7 +292,7 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
- <Show when={reviewTab()}>
+ <Show when={reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
@@ -378,8 +366,10 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
- {reviewCount()}{" "}
- {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
+ {props.reviewCount()}{" "}
+ {language.t(
+ props.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")}
@@ -387,9 +377,9 @@ export function SessionSidePanel(props: {
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
- <Match when={hasReview()}>
+ <Match when={props.hasReview() || !props.diffsReady()}>
<Show
- when={diffsReady()}
+ when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -408,11 +398,7 @@ export function SessionSidePanel(props: {
/>
</Show>
</Match>
- <Match when={true}>
- {empty(
- language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
- )}
- </Match>
+ <Match when={true}>{empty(props.empty())}</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 430fd46f8..239795373 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -52,11 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync.session.get(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 hasReview = () => !!params.id
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 6353ca79a..e8f3e6a11 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/packages/opencode/src/cli/cmd/github.ts
@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
+import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
-import { git } from "@/util/git"
type GitHubAuthor = {
login: string
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
- const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
+ const info = (await Git.run(["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(args, { cwd: Instance.worktree })
+ const result = await Git.run(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(args, { cwd: Instance.worktree })
+ const result = await Git.run(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(args, { cwd: Instance.worktree })
+ const gitStatus = (args: string[]) => Git.run(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 8826fe343..58d42c6ef 100644
--- a/packages/opencode/src/cli/cmd/pr.ts
+++ b/packages/opencode/src/cli/cmd/pr.ts
@@ -1,8 +1,8 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
+import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
-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(["remote"], { cwd: Instance.worktree })).text().trim()
+ const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
- await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
+ await Git.run(["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(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
+ await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 353f02c31..cdcf80a99 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -2,7 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
-import { git } from "@/util/git"
+import { Git } from "@/git"
import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
@@ -419,7 +419,7 @@ export namespace File {
return yield* Effect.promise(async () => {
const diffOutput = (
- await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+ await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
@@ -439,7 +439,7 @@ export namespace File {
}
const untrackedOutput = (
- await git(
+ await Git.run(
[
"-c",
"core.fsmonitor=false",
@@ -472,7 +472,7 @@ export namespace File {
}
const deletedOutput = (
- await git(
+ await Git.run(
[
"-c",
"core.fsmonitor=false",
@@ -560,17 +560,17 @@ export namespace File {
if (Instance.project.vcs === "git") {
return yield* Effect.promise(async (): Promise<File.Content> => {
let diff = (
- await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
+ await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
if (!diff.trim()) {
diff = (
- await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+ await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
})
).text()
}
if (diff.trim()) {
- const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
+ const original = (await Git.run(["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 73c40e133..b78b3a33a 100644
--- a/packages/opencode/src/file/watcher.ts
+++ b/packages/opencode/src/file/watcher.ts
@@ -10,8 +10,8 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
+import { Git } from "@/git"
import { Instance } from "@/project/instance"
-import { git } from "@/util/git"
import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { FileIgnore } from "./ignore"
@@ -132,7 +132,7 @@ export namespace FileWatcher {
if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
- git(["rev-parse", "--git-dir"], {
+ Git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
}),
)
diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts
new file mode 100644
index 000000000..2b3a8a9b0
--- /dev/null
+++ b/packages/opencode/src/git/index.ts
@@ -0,0 +1,303 @@
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { makeRuntime } from "@/effect/run-service"
+
+export namespace Git {
+ 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
+
+ const out = (result: { text(): string }) => result.text().trim()
+ const nuls = (text: string) => text.split("\0").filter(Boolean)
+ const fail = (err: unknown) =>
+ ({
+ exitCode: 1,
+ text: () => "",
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
+ }) satisfies Result
+
+ 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 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[]>
+ }
+
+ const kind = (code: string): 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"
+ }
+
+ 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,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ 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(fail(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 })
+ const name = out(result)
+ if (!name || !list.includes(name)) return
+ return { name, ref: name } 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(["symbolic-ref", "--quiet", "--short", "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 list = yield* refs(cwd)
+ const next = yield* configured(cwd, list)
+ if (next) return next
+ if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
+ if (list.includes("master")) return { name: "master", ref: "master" } 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 ""
+ if (result.stdout.includes(0)) return ""
+ return result.text()
+ })
+
+ const status = Effect.fn("Git.status")(function* (cwd: string) {
+ return nuls(
+ yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
+ cwd,
+ }),
+ ).flatMap((item) => {
+ const file = item.slice(3)
+ if (!file) return []
+ const code = item.slice(0, 2)
+ return [{ file, code, status: kind(code) } satisfies Item]
+ })
+ })
+
+ const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
+ const list = nuls(
+ yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
+ )
+ return list.flatMap((code, idx) => {
+ if (idx % 2 !== 0) return []
+ const file = list[idx + 1]
+ if (!code || !file) return []
+ return [{ file, code, status: kind(code) } satisfies Item]
+ })
+ })
+
+ const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
+ return nuls(
+ yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
+ ).flatMap((item) => {
+ const a = item.indexOf("\t")
+ const b = item.indexOf("\t", a + 1)
+ if (a === -1 || b === -1) return []
+ const file = item.slice(b + 1)
+ if (!file) return []
+ 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)
+ return [
+ {
+ file,
+ additions: Number.isFinite(additions) ? additions : 0,
+ deletions: Number.isFinite(deletions) ? deletions : 0,
+ } satisfies Stat,
+ ]
+ })
+ })
+
+ return Service.of({
+ run,
+ branch,
+ prefix,
+ defaultBranch,
+ hasHead,
+ mergeBase,
+ show,
+ status,
+ diff,
+ stats,
+ })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
+
+ const { runPromise } = makeRuntime(Service, defaultLayer)
+
+ export async function run(args: string[], opts: Options) {
+ return runPromise((git) => git.run(args, opts))
+ }
+
+ export async function branch(cwd: string) {
+ return runPromise((git) => git.branch(cwd))
+ }
+
+ export async function prefix(cwd: string) {
+ return runPromise((git) => git.prefix(cwd))
+ }
+
+ export async function defaultBranch(cwd: string) {
+ return runPromise((git) => git.defaultBranch(cwd))
+ }
+
+ export async function hasHead(cwd: string) {
+ return runPromise((git) => git.hasHead(cwd))
+ }
+
+ export async function mergeBase(cwd: string, base: string, head?: string) {
+ return runPromise((git) => git.mergeBase(cwd, base, head))
+ }
+
+ export async function show(cwd: string, ref: string, file: string, prefix?: string) {
+ return runPromise((git) => git.show(cwd, ref, file, prefix))
+ }
+
+ export async function status(cwd: string) {
+ return runPromise((git) => git.status(cwd))
+ }
+
+ export async function diff(cwd: string, ref: string) {
+ return runPromise((git) => git.diff(cwd, ref))
+ }
+
+ export async function stats(cwd: string, ref: string) {
+ return runPromise((git) => git.stats(cwd, ref))
+ }
+}
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
index 25f172b8a..5142079b1 100644
--- a/packages/opencode/src/project/vcs.ts
+++ b/packages/opencode/src/project/vcs.ts
@@ -1,17 +1,111 @@
import { Effect, Layer, ServiceMap, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
-import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher"
+import { Git } from "@/git"
+import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
+import { Instance } from "./instance"
import z from "zod"
export namespace Vcs {
const log = Log.create({ service: "vcs" })
+ const 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")
+ })
+
+ const nums = (list: Git.Stat[]) =>
+ new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
+
+ const merge = (...lists: Git.Item[][]) => {
+ const out = new Map<string, Git.Item>()
+ lists.flat().forEach((item) => {
+ if (!out.has(item.file)) out.set(item.file, item)
+ })
+ return [...out.values()]
+ }
+
+ const files = Effect.fnUntraced(function* (
+ fs: AppFileSystem.Interface,
+ git: Git.Interface,
+ cwd: string,
+ ref: string | undefined,
+ list: Git.Item[],
+ map: 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 = map.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: Git.Interface,
+ cwd: string,
+ ref: string | undefined,
+ ) {
+ if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
+ const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
+ return yield* files(fs, git, cwd, ref, list, nums(stats))
+ })
+
+ const compare = Effect.fnUntraced(function* (
+ fs: AppFileSystem.Interface,
+ git: Git.Interface,
+ cwd: string,
+ ref: string,
+ ) {
+ const [list, stats, 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 === "??"),
+ ),
+ nums(stats),
+ )
+ })
+
+ export const Mode = z.enum(["git", "branch"])
+ export type Mode = z.infer<typeof Mode>
+
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
@@ -24,6 +118,7 @@ export namespace Vcs {
export const Info = z
.object({
branch: z.string().optional(),
+ default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
@@ -33,57 +128,45 @@ export namespace Vcs {
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
+ readonly defaultBranch: () => Effect.Effect<string | undefined>
+ readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
}
interface State {
current: string | undefined
+ root: Git.Base | undefined
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
- export const layer: Layer.Layer<Service, never, Bus.Service | ChildProcessSpawner.ChildProcessSpawner> = Layer.effect(
+ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const git = yield* Git.Service
const bus = yield* Bus.Service
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-
- const git = Effect.fnUntraced(
- function* (args: string[], opts: { cwd: string }) {
- const handle = yield* spawner.spawn(
- ChildProcess.make("git", args, { cwd: opts.cwd, extendEnv: true, stdin: "ignore" }),
- )
- const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
- const code = yield* handle.exitCode
- return { code, text }
- },
- Effect.scoped,
- Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), text: "" })),
- )
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () {
if (ctx.project.vcs !== "git") {
- return { current: undefined }
+ return { current: undefined, root: undefined }
}
- const getBranch = Effect.fnUntraced(function* () {
- const result = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.worktree })
- if (result.code !== 0) return undefined
- const text = result.text.trim()
- return text || undefined
+ const get = Effect.fnUntraced(function* () {
+ return yield* git.branch(ctx.directory)
})
-
- const value = {
- current: yield* getBranch(),
- }
- log.info("initialized", { branch: value.current })
+ const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
+ concurrency: 2,
+ })
+ const value = { current, root }
+ log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
- Stream.runForEach(() =>
+ Stream.runForEach((_evt) =>
Effect.gen(function* () {
- const next = yield* getBranch()
+ const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
@@ -106,19 +189,52 @@ export namespace Vcs {
branch: Effect.fn("Vcs.branch")(function* () {
return yield* InstanceState.use(state, (x) => x.current)
}),
+ defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
+ return yield* InstanceState.use(state, (x) => x.root?.name)
+ }),
+ diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
+ const value = yield* InstanceState.get(state)
+ if (Instance.project.vcs !== "git") return []
+ if (mode === "git") {
+ return yield* track(
+ fs,
+ git,
+ Instance.directory,
+ (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
+ )
+ }
+
+ if (!value.root) return []
+ if (value.current && value.current === value.root.name) return []
+ const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
+ if (!ref) return []
+ return yield* compare(fs, git, Instance.directory, ref)
+ }),
})
}),
)
- export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(CrossSpawnSpawner.defaultLayer))
+ const defaultLayer = layer.pipe(
+ Layer.provide(Git.defaultLayer),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(Bus.layer),
+ )
const { runPromise } = makeRuntime(Service, defaultLayer)
- export function init() {
+ export async function init() {
return runPromise((svc) => svc.init())
}
- export function branch() {
+ export async function branch() {
return runPromise((svc) => svc.branch())
}
+
+ export async function defaultBranch() {
+ return runPromise((svc) => svc.defaultBranch())
+ }
+
+ export async function diff(mode: Mode) {
+ return runPromise((svc) => svc.diff(mode))
+ }
}
diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts
index 4bb6efaf9..0186bf467 100644
--- a/packages/opencode/src/server/instance.ts
+++ b/packages/opencode/src/server/instance.ts
@@ -1,4 +1,4 @@
-import { describeRoute, resolver } from "hono-openapi"
+import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import z from "zod"
@@ -16,6 +16,7 @@ import { Command } from "../command"
import { Flag } from "../flag/flag"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
+import { Snapshot } from "@/snapshot"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
import { PtyRoutes } from "./routes/pty"
@@ -134,13 +135,41 @@ export const InstanceRoutes = (app?: Hono) =>
},
}),
async (c) => {
- const branch = await Vcs.branch()
+ const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.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) => {
+ return c.json(await Vcs.diff(c.req.valid("query").mode))
+ },
+ )
+ .get(
"/command",
describeRoute({
summary: "List commands",
diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts
index 268b18f68..0d0dce726 100644
--- a/packages/opencode/src/storage/storage.ts
+++ b/packages/opencode/src/storage/storage.ts
@@ -3,10 +3,10 @@ import path from "path"
import { Global } from "../global"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
-import { git } from "@/util/git"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
+import { Git } from "@/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -111,7 +111,7 @@ export namespace Storage {
if (!worktree) continue
if (!(yield* fs.isDir(worktree))) continue
const result = yield* Effect.promise(() =>
- git(["rev-list", "--max-parents=0", "--all"], {
+ Git.run(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
}),
)
diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts
deleted file mode 100644
index 731131357..000000000
--- a/packages/opencode/src/util/git.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-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 da20a5d6d..b34364ccd 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -12,6 +12,7 @@ import { Slug } from "@opencode-ai/util/slug"
import { errorMessage } from "../util/error"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
+import { Git } from "@/git"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
@@ -515,56 +516,24 @@ export namespace Worktree {
const worktreePath = entry.path
- const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
- if (remoteList.code !== 0) {
- throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
- }
-
- const remotes = remoteList.text
- .split("\n")
- .map((l) => l.trim())
- .filter(Boolean)
- const remote = remotes.includes("origin")
- ? "origin"
- : remotes.length === 1
- ? remotes[0]
- : remotes.includes("upstream")
- ? "upstream"
- : ""
-
- const remoteHead = remote
- ? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
- : { code: 1, text: "", stderr: "" }
-
- const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
- const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
- const remoteBranch =
- remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
-
- const [mainCheck, masterCheck] = yield* Effect.all(
- [
- git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
- git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
- ],
- { concurrency: 2 },
- )
- const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
-
- const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
- if (!target) {
+ const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
+ if (!base) {
throw new ResetFailedError({ message: "Default branch not found" })
}
- if (remoteBranch) {
+ const sep = base.ref.indexOf("/")
+ if (base.ref !== base.name && sep > 0) {
+ const remote = base.ref.slice(0, sep)
+ const branch = base.ref.slice(sep + 1)
yield* gitExpect(
- ["fetch", remote, remoteBranch],
+ ["fetch", remote, branch],
{ cwd: Instance.worktree },
- (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
+ (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
)
}
yield* gitExpect(
- ["reset", "--hard", target],
+ ["reset", "--hard", base.ref],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
)
diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts
new file mode 100644
index 000000000..a897a38e6
--- /dev/null
+++ b/packages/opencode/test/git/git.test.ts
@@ -0,0 +1,128 @@
+import { $ } from "bun"
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { ManagedRuntime } from "effect"
+import { Git } from "../../src/git"
+import { tmpdir } from "../fixture/fixture"
+
+const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
+
+async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
+ const rt = ManagedRuntime.make(Git.defaultLayer)
+ try {
+ return await body(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(Git.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(Git.Service.use((git) => git.branch(tmp.path)))
+ expect(branch).toBeUndefined()
+ })
+ })
+
+ test("branch() returns undefined for detached HEAD", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
+ await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
+
+ await withGit(async (rt) => {
+ const branch = await rt.runPromise(Git.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(Git.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 })
+ await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
+
+ await withGit(async (rt) => {
+ const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
+ expect(status).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ file: weird,
+ 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()
+ await fs.writeFile(path.join(tmp.path, weird), "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, weird), "after\n", "utf-8")
+
+ await withGit(async (rt) => {
+ const [base, diff, stats] = await Promise.all([
+ rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
+ rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
+ rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
+ ])
+
+ expect(base).toBeTruthy()
+ expect(diff).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ file: weird,
+ status: "modified",
+ }),
+ ]),
+ )
+ expect(stats).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ file: weird,
+ additions: 1,
+ deletions: 1,
+ }),
+ ]),
+ )
+ })
+ })
+
+ test("show() returns empty text for binary blobs", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
+ await $`git add .`.cwd(tmp.path).quiet()
+ await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
+
+ await withGit(async (rt) => {
+ const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
+ expect(text).toBe("")
+ })
+ })
+})
diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts
index 346f55039..a327f65fa 100644
--- a/packages/opencode/test/project/vcs.test.ts
+++ b/packages/opencode/test/project/vcs.test.ts
@@ -8,8 +8,13 @@ import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
import { Vcs } from "../../src/project/vcs"
+// Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({
directory,
@@ -22,8 +27,20 @@ async function withVcs(directory: string, body: () => Promise<void>) {
})
}
+function withVcsOnly(directory: string, body: () => Promise<void>) {
+ return Instance.provide({
+ directory,
+ fn: async () => {
+ Vcs.init()
+ await 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) {
return new Promise<string | undefined>((resolve, reject) => {
let settled = false
@@ -49,6 +66,10 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
})
}
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
describeVcs("Vcs", () => {
afterEach(async () => {
await Instance.disposeAll()
@@ -82,11 +103,7 @@ describeVcs("Vcs", () => {
const pending = nextBranchUpdate(tmp.path)
const head = path.join(tmp.path, ".git", "HEAD")
- await fs.writeFile(
- head,
- `ref: refs/heads/${branch}
-`,
- )
+ await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
const updated = await pending
expect(updated).toBe(branch)
@@ -102,11 +119,7 @@ describeVcs("Vcs", () => {
const pending = nextBranchUpdate(tmp.path)
const head = path.join(tmp.path, ".git", "HEAD")
- await fs.writeFile(
- head,
- `ref: refs/heads/${branch}
-`,
- )
+ await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending
const current = await Vcs.branch()
@@ -114,3 +127,102 @@ describeVcs("Vcs", () => {
})
})
})
+
+describe("Vcs diff", () => {
+ afterEach(async () => {
+ await 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 () => {
+ const branch = await Vcs.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 () => {
+ const branch = await Vcs.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 () => {
+ const [branch, base] = await Promise.all([Vcs.branch(), Vcs.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 () => {
+ const diff = await Vcs.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 })
+ await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
+
+ await withVcsOnly(tmp.path, async () => {
+ const diff = await Vcs.diff("git")
+ expect(diff).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ file: weird,
+ 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 () => {
+ const diff = await Vcs.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 113b3ed0f..3a780e234 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -175,6 +175,7 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
+ VcsDiffResponses,
VcsGetResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
@@ -3848,6 +3849,38 @@ 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 2f8e99cfe..d517abf2c 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -2003,6 +2003,7 @@ export type Path = {
export type VcsInfo = {
branch?: string
+ default_branch?: string
}
export type Command = {
@@ -5065,6 +5066,26 @@ 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/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 1040aa292..3b582d66f 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -359,11 +359,10 @@ export const SessionReview = (props: SessionReviewProps) => {
<Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
- <For each={files()}>
- {(file) => {
+ <For each={props.diffs}>
+ {(diff) => {
let wrapper: HTMLDivElement | undefined
-
- const item = createMemo(() => diffs().get(file)!)
+ const file = diff.file
const expanded = createMemo(() => open().includes(file))
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
@@ -372,9 +371,9 @@ export const SessionReview = (props: SessionReviewProps) => {
const comments = createMemo(() => grouped().get(file) ?? [])
const commentedLines = createMemo(() => comments().map((c) => c.selection))
- const beforeText = () => (typeof item().before === "string" ? item().before : "")
- const afterText = () => (typeof item().after === "string" ? item().after : "")
- const changedLines = () => item().additions + item().deletions
+ const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
+ const afterText = () => (typeof diff.after === "string" ? diff.after : "")
+ const changedLines = () => diff.additions + diff.deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
const tooLarge = createMemo(() => {
@@ -385,9 +384,9 @@ export const SessionReview = (props: SessionReviewProps) => {
})
const isAdded = () =>
- item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
+ diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
- item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
+ diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const selectedLines = createMemo(() => {
const current = selection()
@@ -425,7 +424,7 @@ export const SessionReview = (props: SessionReviewProps) => {
file,
selection,
comment,
- preview: selectionPreview(item(), selection),
+ preview: selectionPreview(diff, selection),
})
},
onUpdate: ({ id, comment, selection }) => {
@@ -434,7 +433,7 @@ export const SessionReview = (props: SessionReviewProps) => {
file,
selection,
comment,
- preview: selectionPreview(item(), selection),
+ preview: selectionPreview(diff, selection),
})
},
onDelete: (comment) => {
@@ -513,7 +512,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
- <DiffChanges changes={item()} />
+ <DiffChanges changes={diff} />
</div>
</Match>
<Match when={isDeleted()}>
@@ -527,7 +526,7 @@ export const SessionReview = (props: SessionReviewProps) => {
</span>
</Match>
<Match when={true}>
- <DiffChanges changes={item()} />
+ <DiffChanges changes={diff} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
@@ -582,7 +581,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={fileComponent}
mode="diff"
- preloadedDiff={item().preloaded}
+ preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
@@ -599,17 +598,17 @@ export const SessionReview = (props: SessionReviewProps) => {
commentedLines={commentedLines()}
before={{
name: file,
- contents: typeof item().before === "string" ? item().before : "",
+ contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: file,
- contents: typeof item().after === "string" ? item().after : "",
+ contents: typeof diff.after === "string" ? diff.after : "",
}}
media={{
mode: "auto",
path: file,
- before: item().before,
- after: item().after,
+ before: diff.before,
+ after: diff.after,
readFile: props.readFile,
}}
/>
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index e66b55092..837fd5afc 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -1,5 +1,7 @@
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",