Просмотр исходного кода

feat: add git-backed session review modes (#17961)

Shoubhit Dash 5 месяцев назад
Родитель
Сommit
e6f5214779

+ 1 - 1
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?.branch) input.vcsCache.setStore("value", next)
+      if (next) input.vcsCache.setStore("value", next)
     }),
     input.sdk.permission.list().then((x) => {
       const grouped = groupBySession(

+ 2 - 2
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 = { branch: props.branch }
+      const next = { ...input.store.vcs, branch: props.branch }
       input.setStore("vcs", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)
       break

+ 2 - 0
packages/app/src/i18n/en.ts

@@ -533,6 +533,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",

+ 168 - 24
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 {
   batch,
@@ -57,6 +57,9 @@ 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
@@ -415,15 +418,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(() => !!params.id)
   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
@@ -499,11 +503,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] = createStore({
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
     sending: {} as Record<string, string | undefined>,
@@ -531,6 +546,40 @@ 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()
@@ -546,7 +595,38 @@ 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[] = []
+    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 newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
@@ -615,10 +695,10 @@ export default function Page() {
   const diffsReady = createMemo(() => {
     const id = params.id
     if (!id) return true
-    if (!hasReview()) return true
+    if (!hasSessionReview()) 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"
@@ -741,13 +821,23 @@ export default function Page() {
       sessionKey,
       () => {
         setStore("messageId", undefined)
-        setStore("changes", "session")
+        setStore("changes", "git")
         setUi("pendingMessage", undefined)
       },
       { defer: true },
     ),
   )
 
+  createEffect(
+    on(
+      () => sdk.directory,
+      () => {
+        resetVcs()
+      },
+      { defer: true },
+    ),
+  )
+
   createEffect(
     on(
       () => params.dir,
@@ -870,6 +960,38 @@ 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)
@@ -916,21 +1038,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"
@@ -939,20 +1063,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 (store.changes === "turn") {
+      return empty(reviewEmptyText())
+    }
 
-    if (hasReview() && !diffsReady()) {
+    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">
@@ -972,7 +1110,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>
     )
   }
@@ -1076,7 +1214,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
@@ -1808,6 +1946,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}

+ 20 - 34
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"
 
@@ -19,7 +20,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"
@@ -27,6 +27,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
@@ -34,12 +40,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)")
 
@@ -54,24 +59,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
@@ -82,7 +70,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"
 
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
     pathFromTab: file.pathFromTab,
     normalizeTab,
     review: reviewTab,
-    hasReview,
+    hasReview: props.canReview,
   })
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
@@ -241,12 +229,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>
@@ -303,7 +291,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>
@@ -377,8 +365,8 @@ 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")}
@@ -386,9 +374,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,9 +396,7 @@ export function SessionSidePanel(props: {
                       </Show>
                     </Match>
                     <Match when={true}>
-                      {empty(
-                        language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
-                      )}
+                      {empty(props.empty())}
                     </Match>
                   </Switch>
                 </Tabs.Content>

+ 1 - 5
packages/app/src/pages/session/use-session-commands.tsx

@@ -56,11 +56,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)

+ 5 - 5
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 "@/util/git"
+import { Git } from "@/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>`)

+ 4 - 4
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 "@/util/git"
+import { Git } from "@/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,
               })
             }

+ 1 - 1
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.layer),
+    Layer.fresh(Vcs.defaultLayer),
     Layer.fresh(FileTime.layer).pipe(Layer.orDie),
     Layer.fresh(Format.layer),
     Layer.fresh(File.layer),

+ 2 - 0
packages/opencode/src/effect/runtime.ts

@@ -1,6 +1,7 @@
 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"
@@ -9,6 +10,7 @@ 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)),

+ 7 - 7
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 "@/util/git"
+import { Git } from "@/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(["-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()
@@ -460,7 +460,7 @@ export namespace File {
           }
 
           const untrackedOutput = (
-            await git(
+            await Git.run(
               [
                 "-c",
                 "core.fsmonitor=false",
@@ -493,7 +493,7 @@ export namespace File {
           }
 
           const deletedOutput = (
-            await git(
+            await Git.run(
               [
                 "-c",
                 "core.fsmonitor=false",
@@ -584,17 +584,17 @@ export namespace File {
 
           if (instance.project.vcs === "git") {
             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,

+ 2 - 2
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 "@/util/git"
+import { Git } from "@/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(["rev-parse", "--git-dir"], {
+          Git.run(["rev-parse", "--git-dir"], {
             cwd: instance.project.worktree,
           }),
         )

+ 298 - 0
packages/opencode/src/git/effect.ts

@@ -0,0 +1,298 @@
+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))
+}

+ 64 - 0
packages/opencode/src/git/index.ts

@@ -0,0 +1,64 @@
+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))
+  }
+}

+ 5 - 5
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 "../util/git"
+import { Git } from "@/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(["rev-parse", "--git-common-dir"], {
+        const worktree = await Git.run(["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(["rev-list", "--max-parents=0", "HEAD"], {
+          const roots = await Git.run(["rev-list", "--max-parents=0", "HEAD"], {
             cwd: sandbox,
           })
             .then(async (result) =>
@@ -184,7 +184,7 @@ export namespace Project {
           }
         }
 
-        const top = await git(["rev-parse", "--show-toplevel"], {
+        const top = await Git.run(["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(["init", "--quiet"], {
+    const result = await Git.run(["init", "--quiet"], {
       cwd: input.directory,
     })
     if (result.exitCode !== 0) {

+ 148 - 21
packages/opencode/src/project/vcs.ts

@@ -1,16 +1,122 @@
+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 { git } from "@/util/git"
+import path from "path"
 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",
@@ -22,7 +128,8 @@ export namespace Vcs {
 
   export const Info = z
     .object({
-      branch: z.string(),
+      branch: z.string().optional(),
+      default_branch: z.string().optional(),
     })
     .meta({
       ref: "VcsInfo",
@@ -31,6 +138,8 @@ 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") {}
@@ -39,20 +148,18 @@ export namespace Vcs {
     Service,
     Effect.gen(function* () {
       const instance = yield* InstanceContext
-      let currentBranch: string | undefined
+      const fs = yield* AppFileSystem.Service
+      const git = yield* GitEffect.Service
+      let current: string | undefined
+      let root: GitEffect.Base | undefined
 
       if (instance.project.vcs === "git") {
-        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 })
+        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 })
 
         yield* Effect.acquireRelease(
           Effect.sync(() =>
@@ -60,12 +167,11 @@ export namespace Vcs {
               FileWatcher.Event.Updated,
               Instance.bind(async (evt) => {
                 if (!evt.properties.file.endsWith("HEAD")) return
-                const next = await getCurrentBranch()
-                if (next !== currentBranch) {
-                  log.info("branch changed", { from: currentBranch, to: next })
-                  currentBranch = next
-                  Bus.publish(Event.BranchUpdated, { branch: next })
-                }
+                const next = await get()
+                if (next === current) return
+                log.info("branch changed", { from: current, to: next })
+                current = next
+                Bus.publish(Event.BranchUpdated, { branch: next })
               }),
             ),
           ),
@@ -75,9 +181,30 @@ export namespace Vcs {
 
       return Service.of({
         branch: Effect.fn("Vcs.branch")(function* () {
-          return currentBranch
+          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)
         }),
       })
     }),
   )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(GitEffect.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+  )
 }

+ 34 - 4
packages/opencode/src/server/server.ts

@@ -41,6 +41,7 @@ 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"
@@ -332,10 +333,39 @@ export namespace Server {
           },
         }),
         async (c) => {
-          const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
-          return c.json({
-            branch,
-          })
+          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))))
         },
       )
       .get(

+ 2 - 2
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 "@/util/git"
+import { Git } from "@/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(["rev-list", "--max-parents=0", "--all"], {
+          const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
             cwd: worktree,
           })
           const [id] = result

+ 0 - 35
packages/opencode/src/util/git.ts

@@ -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)),
-    }))
-}

+ 22 - 22
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 "../util/git"
+import { Git } from "@/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(["clean", "-ffdx"], { cwd: root })
+    const first = await Git.run(["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(["clean", "-ffdx"], { cwd: root })
+    return Git.run(["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(["show-ref", "--verify", "--quiet", ref], {
+      const branchCheck = await Git.run(["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(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
+    const created = await Git.run(["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(["reset", "--hard"], { cwd: info.directory })
+        const populated = await Git.run(["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(["fsmonitor--daemon", "stop"], { cwd: target })
+      await Git.run(["fsmonitor--daemon", "stop"], { cwd: target })
     }
 
-    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+    const list = await Git.run(["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(["worktree", "remove", "--force", entry.path], {
+    const removed = await Git.run(["worktree", "remove", "--force", entry.path], {
       cwd: Instance.worktree,
     })
     if (removed.exitCode !== 0) {
-      const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+      const next = await Git.run(["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(["branch", "-D", branch], { cwd: Instance.worktree })
+      const deleted = await Git.run(["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(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+    const list = await Git.run(["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(["remote"], { cwd: Instance.worktree })
+    const remoteList = await Git.run(["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(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
+      ? await Git.run(["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(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
+    const mainCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
       cwd: Instance.worktree,
     })
-    const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
+    const masterCheck = await Git.run(["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(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
+      const fetch = await Git.run(["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(["reset", "--hard", target], { cwd: worktreePath })
+    const resetToTarget = await Git.run(["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(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
+    const update = await Git.run(["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(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
+    const subReset = await Git.run(["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(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
+    const subClean = await Git.run(["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(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
+    const status = await Git.run(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
     if (status.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
     }

+ 107 - 0
packages/opencode/test/git/git.test.ts

@@ -0,0 +1,107 @@
+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,
+          }),
+        ]),
+      )
+    })
+  })
+})

+ 10 - 7
packages/opencode/test/project/project.test.ts

@@ -10,14 +10,16 @@ import { ProjectID } from "../../src/project/schema"
 
 Log.init({ print: false })
 
-const gitModule = await import("../../src/util/git")
-const originalGit = gitModule.git
+const gitModule = await import("../../src/git")
+const originalGit = gitModule.Git.run
 
 type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
 let mode: Mode = "none"
 
-mock.module("../../src/util/git", () => ({
-  git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
+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" &&
@@ -27,7 +29,7 @@ mock.module("../../src/util/git", () => ({
     ) {
       return Promise.resolve({
         exitCode: 128,
-        text: () => Promise.resolve(""),
+        text: () => "",
         stdout: Buffer.from(""),
         stderr: Buffer.from("fatal"),
       })
@@ -35,7 +37,7 @@ mock.module("../../src/util/git", () => ({
     if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
       return Promise.resolve({
         exitCode: 128,
-        text: () => Promise.resolve(""),
+        text: () => "",
         stdout: Buffer.from(""),
         stderr: Buffer.from("fatal"),
       })
@@ -43,12 +45,13 @@ mock.module("../../src/util/git", () => ({
     if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
       return Promise.resolve({
         exitCode: 128,
-        text: () => Promise.resolve(""),
+        text: () => "",
         stdout: Buffer.from(""),
         stderr: Buffer.from("fatal"),
       })
     }
     return originalGit(args, opts)
+    },
   },
 }))
 

+ 110 - 1
packages/opencode/test/project/vcs.test.ts

@@ -23,7 +23,7 @@ function withVcs(
 ) {
   return withServices(
     directory,
-    Layer.merge(FileWatcher.layer, Vcs.layer),
+    Layer.merge(FileWatcher.layer, Vcs.defaultLayer),
     async (rt) => {
       await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
       await rt.runPromise(Vcs.Service.use(() => Effect.void))
@@ -34,7 +34,15 @@ 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) {
@@ -121,3 +129,104 @@ 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",
+          }),
+        ]),
+      )
+    })
+  })
+})

+ 33 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -172,6 +172,7 @@ import type {
   TuiSelectSessionResponses,
   TuiShowToastResponses,
   TuiSubmitPromptResponses,
+  VcsDiffResponses,
   VcsGetResponses,
   WorktreeCreateErrors,
   WorktreeCreateInput,
@@ -3661,6 +3662,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 {

+ 22 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1889,7 +1889,8 @@ export type Path = {
 }
 
 export type VcsInfo = {
-  branch: string
+  branch?: string
+  default_branch?: string
 }
 
 export type Command = {
@@ -4833,6 +4834,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

+ 2 - 0
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",