diff options
| author | Shoubhit Dash <[email protected]> | 2026-04-03 20:24:57 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-03 09:54:57 -0500 |
| commit | 35350b1d25a56665cf065eba68929fc00617fdd2 (patch) | |
| tree | 91bf53b5d87ff9532ebf0a779c4dbe7bc99148f3 | |
| parent | 263dcf75b548810a149f08ea5e32e0f6754128d5 (diff) | |
| download | opencode-35350b1d25a56665cf065eba68929fc00617fdd2.tar.gz opencode-35350b1d25a56665cf065eba68929fc00617fdd2.zip | |
feat: restore git-backed review modes (#20845)
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", |
