mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-26 18:47:35 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c967c4e1 |
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user