Compare commits

...
Author SHA1 Message Date
Brendonovich 81c967c4e1 fix(app): restore Git initialization for non-Git projects 2026-09-26 06:28:01 +00:00
16 changed files with 274 additions and 24 deletions
@@ -0,0 +1,67 @@
import { base64Encode } from "@opencode/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewWithoutGit"
const sessionID = "ses_review_without_git"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
for (const view of ["desktop", "mobile"] as const) {
test(`offers Git initialization instead of an empty changes selector (${view})`, async ({ page }) => {
test.setTimeout(180_000)
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
const project = {
id: "proj_review_without_git",
worktree: directory,
vcs: undefined as string | undefined,
name: "review-without-git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
const session = {
id: sessionID,
slug: sessionID,
projectID: project.id,
directory,
title: "Review without Git",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
const requests: string[] = []
await page.route("**/api/vcs/init*", (route) => {
requests.push(route.request().url())
project.id = "proj_review_with_git"
project.vcs = "git"
session.projectID = project.id
return route.fulfill({ status: 204 })
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "domcontentloaded" })
if (view === "desktop") {
await expectSessionTitle(page, "Review without Git")
await page.getByRole("button", { name: "Toggle review" }).click()
} else {
await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
}
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator("[data-component='session-review']")
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
const init = panel.getByRole("button", { name: "Create Git repository" })
await expect(init).toBeVisible()
await test.info().attach("review-without-git", { body: await panel.screenshot(), contentType: "image/png" })
await init.click()
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
await expect(init).toHaveCount(0)
await test.info().attach("review-after-git", { body: await panel.screenshot(), contentType: "image/png" })
expect(requests).toHaveLength(1)
expect(new URL(requests[0]!).searchParams.get("location[directory]")).toBe(directory)
})
}
+31
View File
@@ -17,6 +17,8 @@ import { createOpenReviewFile } from "../helpers"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { createReviewPanelState } from "./panel-state"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds"
import type { DiffStyle } from "./review-tab"
@@ -42,6 +44,7 @@ export function createSessionReview(input: {
detailsOpen: false,
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
initializingGit: false,
})
const mode = () => input.session.layout.view().review.mode() ?? "git"
const selectedFile = () => input.session.layout.view().review.file()
@@ -161,6 +164,32 @@ export function createSessionReview(input: {
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
const initializeGit = () => {
if (state.initializingGit) return
const directory = location().directory
const sessionID = input.session.identity.params.id
if (!sessionID) return
setState("initializingGit", true)
void server.api.vcs
.init({ location: { directory } })
.then(async () => {
data.project.invalidate()
data.session.invalidate(sessionID)
data.location.invalidate({ directory })
data.location.vcs.invalidate({ directory })
await data.project.sync()
await data.session.sync(sessionID)
await Promise.all([data.location.sync({ directory }), data.location.vcs.sync({ directory })])
})
.catch((error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t),
}),
)
.finally(() => setState("initializingGit", false))
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
@@ -410,6 +439,8 @@ export function createSessionReview(input: {
diffs,
focusFile,
hasChanges,
initializeGit,
initializingGit: () => state.initializingGit,
loadDiff,
mobile: {
changes: mobileChanges,
+7 -17
View File
@@ -1,4 +1,5 @@
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode/session-ui/v2/session-review-empty-no-git-v2"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import { Select } from "@opencode/ui/select"
import { Tabs } from "@opencode/ui/tabs"
@@ -237,7 +238,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview()}>
<Show when={props.review.canReview() && props.review.options().length > 0}>
<Select
options={props.review.options()}
current={props.review.mode()}
@@ -253,7 +254,6 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -264,14 +264,9 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
<Match when={loading()}>
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Match when={props.review.noGit()}>
<div class="h-full flex flex-col">
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
</div>
</Match>
<Match when={true}>
@@ -286,18 +281,13 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<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.noVcs.createGit.description")}
</div>
</div>
<Match when={props.review.noGit()}>
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
</Match>
<Match when={true}>
<SessionReviewEmptyChangesV2 />
+5
View File
@@ -2223,6 +2223,10 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type VcsInitInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type VcsInitOutput = void
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
export type VcsGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type VcsGetOutput = { readonly location: Location.PublicRef; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
@@ -2253,6 +2257,7 @@ export type VcsDiffOutput = { readonly location: Location.PublicRef; readonly da
export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect<VcsDiffOutput, E>
export interface VcsApi<E = never> {
readonly init: VcsInitOperation<E>
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
@@ -239,6 +239,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -1450,6 +1452,11 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointVcsInit = (raw: RawClient["server.vcs"]) => (input?: VcsInitInput) =>
preserveEffect<VcsInitOutput>()(
raw["vcs.init"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1480,6 +1487,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
init: EndpointVcsInit(raw),
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
@@ -237,6 +237,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -2012,6 +2014,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
init: (input?: VcsInitInput, requestOptions?: RequestOptions) =>
request<VcsInitOutput>(
{
method: "POST",
path: `/api/vcs/init`,
query: { location: input?.["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 409, 503],
empty: true,
},
requestOptions,
),
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
@@ -6249,6 +6249,12 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type VcsInitInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type VcsInitOutput = void
export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+16 -1
View File
@@ -36,6 +36,10 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.
projectID: ID,
}) {}
export class InitializeGitError extends Schema.TaggedError<InitializeGitError>()("Project.InitializeGitError", {
kind: Schema.Literals(["missing", "conflict", "failed"]),
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -66,6 +70,7 @@ export interface Interface {
readonly activate: (projectID: ID) => Effect.Effect<void>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
readonly initializeGit: (directory: AbsolutePath) => Effect.Effect<Resolved, InitializeGitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -382,7 +387,17 @@ const layer = Layer.effect(
})
})
return Service.of({ list, update, activate, resolve })
const initializeGit = Effect.fn("Project.initializeGit")(function* (directory: AbsolutePath) {
if (!(yield* fs.isDir(directory))) return yield* new InitializeGitError({ kind: "missing" })
if ((yield* resolve(directory)).vcs) return yield* new InitializeGitError({ kind: "conflict" })
const result = yield* proc
.run(ChildProcess.make("git", ["init"], { cwd: directory, stdin: "ignore" }))
.pipe(Effect.mapError(() => new InitializeGitError({ kind: "failed" })))
if (result.exitCode !== 0) return yield* new InitializeGitError({ kind: "failed" })
return yield* resolve(directory)
})
return Service.of({ list, update, activate, resolve, initializeGit })
}),
)
@@ -101,6 +101,7 @@ describe("node build", () => {
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
initializeGit: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
+1
View File
@@ -14,6 +14,7 @@ export const globalProjectNode = makeGlobalNode({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
initializeGit: () => Effect.die("not implemented"),
resolve: (directory) => {
const project = { id: Project.ID.global, directory, canonical: directory }
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
+1
View File
@@ -15,6 +15,7 @@ const projectLayer = Layer.succeed(
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
initializeGit: () => Effect.die("not implemented"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
+1 -1
View File
@@ -33,7 +33,7 @@ export interface VcsDefinition {
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
}
export interface VcsDomain extends VcsApi<unknown> {
export interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
+1 -1
View File
@@ -32,7 +32,7 @@ export interface VcsDefinition {
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
}
export interface VcsDomain extends VcsApi {
export interface VcsDomain extends Omit<VcsApi, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Promise<void>
}
+17 -2
View File
@@ -3,9 +3,9 @@ import { Location } from "@opencode/schema/location"
import { NonNegativeInt, PositiveInt, optional } from "@opencode/schema/schema"
import { Vcs } from "@opencode/schema/vcs"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ServiceUnavailableError } from "../errors.js"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
const BranchesQuery = Schema.Struct({
...LocationQuery.fields,
@@ -21,6 +21,21 @@ const DiffQuery = Schema.Struct({
})
export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.post("vcs.init", "/api/vcs/init", {
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: [ConflictError, InvalidRequestError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "vcs.init",
summary: "Initialize Git repository",
description: "Initialize Git in a markerless project's directory and refresh its location services.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
+26 -2
View File
@@ -1,13 +1,37 @@
import { Vcs } from "@opencode/core/vcs"
import { ServiceUnavailableError } from "@opencode/protocol/errors"
import { Project } from "@opencode/core/project"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
const project = yield* Project.Service
const locations = yield* LocationServiceMap.Service
return handlers
.handle("vcs.init", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const directory = location.project.directory
yield* project.initializeGit(directory).pipe(
Effect.mapError((error) => {
if (error.kind === "missing")
return new InvalidRequestError({ message: "Project directory does not exist", field: "location" })
if (error.kind === "conflict")
return new ConflictError({ message: "Project already has version control", resource: directory })
return new ServiceUnavailableError({ service: "git", message: "Git initialization failed" })
}),
)
yield* locations.invalidate(
Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
+72
View File
@@ -8,6 +8,78 @@ import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { ServerFetch } from "../src/fetch"
it.live(
"initializes Git in a markerless project and refreshes its location",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
const before = yield* Effect.promise(() =>
fetch(new URL(`/api/location${url.search}`, server.base), { headers: server.headers }),
)
expect(before.status).toBe(200)
const original = yield* Effect.promise(() => before.json())
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => $`git -C ${tmp.path} rev-parse --is-inside-work-tree`.text())).toBe("true\n")
url.pathname = "/api/location"
const location = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(location.status).toBe(200)
const refreshed = yield* Effect.promise(() => location.json())
expect(refreshed).toMatchObject({ project: { directory: tmp.path } })
expect(refreshed.project.id).not.toBe(original.project.id)
const projects = yield* Effect.promise(() =>
fetch(new URL("/api/project", server.base), { headers: server.headers }),
)
expect(yield* Effect.promise(() => projects.json())).toContainEqual(
expect.objectContaining({ id: refreshed.project.id, vcs: "git" }),
)
url.pathname = "/api/vcs"
const vcs = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "git")
throw new Error("Git provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(vcs).toMatchObject({ data: { provider: "git" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.searchParams.delete("mode")
url.pathname = "/api/vcs/init"
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(repeated.status).toBe(409)
}),
15_000,
)
it.live(
"does not create a project directory while initializing Git",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-missing-")))
const server = yield* startServer(path.join(tmp.path, "config"))
const directory = path.join(tmp.path, "absent")
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", directory)
const response = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(response.status).not.toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git", "HEAD")).exists())).toBe(false)
}),
15_000,
)
it.live(
"serves lazy review bases, committed diffs, and unavailable-base errors",
() =>