feat(core): expose V2 VCS status and diff APIs (#35129)
This commit is contained in:
parent
85fde8c11d
commit
a1b6c84135
16 changed files with 970 additions and 0 deletions
|
|
@ -1012,6 +1012,24 @@ const adaptGroup23 = (raw: RawClient["server.projectCopy"]) => ({
|
|||
refresh: Endpoint23_2(raw),
|
||||
})
|
||||
|
||||
type Endpoint24_0Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
|
||||
type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] }
|
||||
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint24_1Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
|
||||
type Endpoint24_1Input = {
|
||||
readonly location?: Endpoint24_1Request["query"]["location"]
|
||||
readonly mode: Endpoint24_1Request["query"]["mode"]
|
||||
readonly context?: Endpoint24_1Request["query"]["context"]
|
||||
}
|
||||
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input) =>
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
location: adaptGroup1(raw["server.location"]),
|
||||
|
|
@ -1037,6 +1055,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||
question: adaptGroup21(raw["server.question"]),
|
||||
reference: adaptGroup22(raw["server.reference"]),
|
||||
projectCopy: adaptGroup23(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ import type {
|
|||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsDiffInput,
|
||||
VcsDiffOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
|
|
@ -1404,6 +1408,32 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
},
|
||||
vcs: {
|
||||
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsStatusOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/status`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsDiffOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/diff`,
|
||||
query: { location: input["location"], mode: input["mode"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5924,3 +5924,56 @@ export type ProjectCopyRefreshInput = {
|
|||
}
|
||||
|
||||
export type ProjectCopyRefreshOutput = void
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type VcsStatusOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly file: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}>
|
||||
}
|
||||
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly context?: number | undefined
|
||||
}["location"]
|
||||
readonly mode: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly context?: number | undefined
|
||||
}["mode"]
|
||||
readonly context?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type VcsDiffOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly file?: string
|
||||
readonly patch?: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status?: "added" | "deleted" | "modified"
|
||||
}>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
"question",
|
||||
"reference",
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
])
|
||||
expect(Object.keys(client.message)).toEqual(["list"])
|
||||
expect(Object.keys(client.integration)).toEqual([
|
||||
|
|
@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
"attemptCancel",
|
||||
])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["current", "directories"])
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { McpTool } from "./tool/mcp"
|
|||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
|
|
@ -96,6 +97,7 @@ export const locationServices = LayerNode.group([
|
|||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
])
|
||||
|
||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
|
|
|
|||
51
packages/core/src/vcs.ts
Normal file
51
packages/core/src/vcs.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export * as Vcs from "./vcs"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "./process"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
|
||||
export { FileStatus, Mode }
|
||||
|
||||
export interface DiffOptions {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Vcs") {}
|
||||
|
||||
// Adapter seam: one working-copy implementation per VCS type, selected by the
|
||||
// resolved location. Locations without a supported VCS degrade to empty
|
||||
// results so callers never need to special-case.
|
||||
const adapter = (proc: AppProcess.Interface, location: Location.Interface) => {
|
||||
if (location.vcs?.type === "git")
|
||||
return VcsGit.make(proc, { directory: location.directory, worktree: location.project.directory })
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const proc = yield* AppProcess.Service
|
||||
const location = yield* Location.Service
|
||||
const impl = adapter(proc, location)
|
||||
return Service.of({
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
if (!impl) return []
|
||||
return yield* impl.status()
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
if (!impl) return []
|
||||
return yield* impl.diff(mode, options)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: layer, deps: [AppProcess.node, Location.node] })
|
||||
521
packages/core/src/vcs/git.ts
Normal file
521
packages/core/src/vcs/git.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
export * as VcsGit from "./git"
|
||||
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { AppProcess } from "../process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
const MAX_PATCH_BYTES = 10_000_000
|
||||
const MAX_TOTAL_PATCH_BYTES = 10_000_000
|
||||
|
||||
/**
|
||||
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
|
||||
* batched through one `git diff` invocation where possible and capped by
|
||||
* per-file and total byte budgets, falling back to empty patches when capped.
|
||||
*/
|
||||
export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Interface {
|
||||
// Listing commands scope pathspecs to the requested directory; per-file
|
||||
// commands run from the worktree root because git lists root-relative paths.
|
||||
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
|
||||
|
||||
return {
|
||||
status: Effect.fn("VcsGit.status")(function* () {
|
||||
const git = ctx.git
|
||||
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
|
||||
const [list, stats] = yield* Effect.all(
|
||||
[git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([] as Stat[])],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const map = nums(stats)
|
||||
return yield* Effect.forEach(
|
||||
list.toSorted((a, b) => a.file.localeCompare(b.file)),
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const stat =
|
||||
map.get(item.file) ??
|
||||
(item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined)
|
||||
return {
|
||||
file: item.file,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
status: item.status,
|
||||
} satisfies FileStatus
|
||||
}),
|
||||
)
|
||||
}),
|
||||
diff: Effect.fn("VcsGit.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
const git = ctx.git
|
||||
if (mode === "working") {
|
||||
return yield* track(ctx, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
|
||||
}
|
||||
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
if (!root) return []
|
||||
if (current && current === root.name) return []
|
||||
const ref = yield* git.mergeBase(ctx.directory, root.ref)
|
||||
if (!ref) return []
|
||||
return yield* diffAgainstRef(ctx, ref, options)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type Kind = FileStatus["status"]
|
||||
|
||||
interface Base {
|
||||
readonly name: string
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
interface Item {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
readonly status: Kind
|
||||
}
|
||||
|
||||
interface Stat {
|
||||
readonly file: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
interface Patch {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
interface PatchOptions {
|
||||
readonly context?: number
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
readonly git: GitOps
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
}
|
||||
|
||||
type GitOps = ReturnType<typeof makeGit>
|
||||
|
||||
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 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"
|
||||
}
|
||||
|
||||
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
||||
|
||||
function makeGit(proc: AppProcess.Interface) {
|
||||
const run = Effect.fnUntraced(
|
||||
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("git", [...cfg, ...args], {
|
||||
cwd: opts.cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ maxOutputBytes: opts.maxOutputBytes },
|
||||
)
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
text: () => result.stdout.toString("utf8"),
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
}
|
||||
},
|
||||
Effect.catch(() => Effect.succeed({ exitCode: 1, text: () => "", truncated: false })),
|
||||
)
|
||||
|
||||
const text = Effect.fnUntraced(function* (args: string[], opts: { cwd: string }) {
|
||||
return (yield* run(args, opts)).text()
|
||||
})
|
||||
|
||||
const lines = Effect.fnUntraced(function* (args: string[], opts: { cwd: string }) {
|
||||
return (yield* text(args, opts))
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||
const name = result.text().trim()
|
||||
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("VcsGit.branch")(function* (cwd: string) {
|
||||
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const defaultBranch = Effect.fn("VcsGit.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 = head.text().trim().replace(/^refs\/remotes\//, "")
|
||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||
if (name) return { name, ref } satisfies Base
|
||||
}
|
||||
}
|
||||
|
||||
const list = yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { 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("VcsGit.hasHead")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
||||
return result.exitCode === 0
|
||||
})
|
||||
|
||||
const mergeBase = Effect.fn("VcsGit.mergeBase")(function* (cwd: string, base: string) {
|
||||
const result = yield* run(["merge-base", base, "HEAD"], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const status = Effect.fn("VcsGit.statusNames")(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("VcsGit.diffNames")(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("VcsGit.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,
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const patch = Effect.fn("VcsGit.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const patchAll = Effect.fn("VcsGit.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const patchUntracked = Effect.fn("VcsGit.patchUntracked")(function* (
|
||||
cwd: string,
|
||||
file: string,
|
||||
options?: PatchOptions,
|
||||
) {
|
||||
const result = yield* run(
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--patch",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
`--unified=${options?.context ?? 3}`,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file,
|
||||
],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const statUntracked = Effect.fn("VcsGit.statUntracked")(function* (cwd: string, file: string) {
|
||||
const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], {
|
||||
cwd,
|
||||
maxOutputBytes: 4096,
|
||||
})
|
||||
if (result.truncated) return
|
||||
|
||||
const parts = result.text().split("\t")
|
||||
if (parts.length < 2) return
|
||||
|
||||
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
|
||||
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
|
||||
return {
|
||||
file,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
} satisfies Stat
|
||||
})
|
||||
|
||||
return {
|
||||
branch,
|
||||
defaultBranch,
|
||||
hasHead,
|
||||
mergeBase,
|
||||
status,
|
||||
diff,
|
||||
stats,
|
||||
patch,
|
||||
patchAll,
|
||||
patchUntracked,
|
||||
statUntracked,
|
||||
}
|
||||
}
|
||||
|
||||
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
|
||||
|
||||
const nums = (list: Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
const merge = (...lists: Item[][]) => {
|
||||
const out = new Map<string, Item>()
|
||||
lists.flat().forEach((item) => {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
})
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
|
||||
|
||||
const parseQuotedPath = (value: string) => {
|
||||
let out = ""
|
||||
for (let idx = 1; idx < value.length; idx++) {
|
||||
const char = value[idx]
|
||||
if (char === '"') return { value: out, end: idx + 1 }
|
||||
if (char !== "\\") {
|
||||
out += char
|
||||
continue
|
||||
}
|
||||
|
||||
const next = value[++idx]
|
||||
if (next === "t") out += "\t"
|
||||
else if (next === "n") out += "\n"
|
||||
else if (next === "r") out += "\r"
|
||||
else if (next === '"' || next === "\\") out += next
|
||||
else out += next ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
const parsePathToken = (value: string) => {
|
||||
if (!value.startsWith('"')) return value.split("\t")[0]
|
||||
return parseQuotedPath(value)?.value ?? value
|
||||
}
|
||||
|
||||
const fileFromDiffPath = (value: string | undefined) => {
|
||||
if (!value || value === "/dev/null") return
|
||||
const file = parsePathToken(value)
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const fileFromGitHeader = (header: string) => {
|
||||
if (header.startsWith('"')) {
|
||||
const first = parseQuotedPath(header)
|
||||
const second = first ? header.slice(first.end).trimStart() : undefined
|
||||
if (!second) return
|
||||
if (!second.startsWith('"')) return fileFromDiffPath(second)
|
||||
return fileFromDiffPath(parseQuotedPath(second)?.value)
|
||||
}
|
||||
|
||||
const separator = header.indexOf(" b/")
|
||||
if (separator === -1) return
|
||||
return fileFromDiffPath(header.slice(separator + 1))
|
||||
}
|
||||
|
||||
const fileFromPatchChunk = (chunk: string) => {
|
||||
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
|
||||
const before = /^--- (.+)$/m.exec(chunk)?.[1]
|
||||
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
|
||||
if (file) return file
|
||||
|
||||
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
|
||||
return fileFromGitHeader(header ?? "")
|
||||
}
|
||||
|
||||
const splitGitPatch = (patch: Patch) => {
|
||||
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
|
||||
if (!patch.truncated) return chunks
|
||||
return chunks.slice(0, -1)
|
||||
}
|
||||
|
||||
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) {
|
||||
if (list.length === 0) return emptyBatch()
|
||||
|
||||
const result = yield* ctx.git.patchAll(ctx.directory, ref, {
|
||||
context: options?.context ?? PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
|
||||
})
|
||||
|
||||
return {
|
||||
patches: splitGitPatch(result).reduce((acc, patch, index) => {
|
||||
const file = fileFromPatchChunk(patch) ?? list[index]?.file
|
||||
if (!file) return acc
|
||||
acc.set(file, (acc.get(file) ?? "") + patch)
|
||||
return acc
|
||||
}, new Map<string, string>()),
|
||||
capped: result.truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, item: Item, options?: DiffOptions) {
|
||||
const result =
|
||||
item.code === "??" || !ref
|
||||
? yield* ctx.git.patchUntracked(ctx.worktree, item.file, {
|
||||
context: options?.context ?? PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_PATCH_BYTES,
|
||||
})
|
||||
: yield* ctx.git.patch(ctx.worktree, ref, item.file, {
|
||||
context: options?.context ?? PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_PATCH_BYTES,
|
||||
})
|
||||
if (!result.truncated && result.text) return result.text
|
||||
|
||||
return emptyPatch(item.file)
|
||||
})
|
||||
|
||||
const totalPatch = (file: string, patch: string, total: number) => {
|
||||
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
|
||||
return { patch: emptyPatch(file), capped: true }
|
||||
}
|
||||
|
||||
const patchForItem = Effect.fnUntraced(function* (
|
||||
ctx: Ctx,
|
||||
ref: string | undefined,
|
||||
item: Item,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
capped: boolean,
|
||||
options?: DiffOptions,
|
||||
) {
|
||||
if (capped) return emptyPatch(item.file)
|
||||
|
||||
const batched = batch.patches.get(item.file)
|
||||
if (batched !== undefined) return batched
|
||||
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
|
||||
return yield* nativePatch(ctx, ref, item, options)
|
||||
})
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
ctx: Ctx,
|
||||
ref: string | undefined,
|
||||
list: Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
options?: DiffOptions,
|
||||
) {
|
||||
const next: FileDiff.Info[] = []
|
||||
let total = 0
|
||||
let capped = false
|
||||
|
||||
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
|
||||
const stat =
|
||||
map.get(item.file) ??
|
||||
(item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
|
||||
const patch = yield* patchForItem(ctx, ref, item, batch, capped, options)
|
||||
const result: { patch: string; capped: boolean } = capped
|
||||
? { patch, capped: true }
|
||||
: totalPatch(item.file, patch, total)
|
||||
capped = capped || result.capped
|
||||
if (!capped) {
|
||||
total += Buffer.byteLength(result.patch)
|
||||
capped = total >= MAX_TOTAL_PATCH_BYTES
|
||||
}
|
||||
next.push({
|
||||
file: item.file,
|
||||
patch: result.patch,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
status: item.status,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: DiffOptions) {
|
||||
const [list, stats, extra] = yield* Effect.all(
|
||||
[ctx.git.diff(ctx.directory, ref), ctx.git.stats(ctx.directory, ref), ctx.git.status(ctx.directory)],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
return yield* files(
|
||||
ctx,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
yield* batchPatches(ctx, ref, list, options),
|
||||
options,
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, options?: DiffOptions) {
|
||||
if (!ref) return yield* files(ctx, ref, yield* ctx.git.status(ctx.directory), new Map(), emptyBatch(), options)
|
||||
return yield* diffAgainstRef(ctx, ref, options)
|
||||
})
|
||||
173
packages/core/test/vcs.test.ts
Normal file
173
packages/core/test/vcs.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init -b main`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
async function commitAll(directory: string, message: string) {
|
||||
await $`git add -A`.cwd(directory).quiet()
|
||||
await $`git commit -m ${message}`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("Vcs", () => {
|
||||
it.live("returns empty results outside version control", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports modified, deleted, and untracked files", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
|
||||
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
|
||||
await commitAll(directory, "initial")
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
|
||||
await fs.rm(path.join(directory, "gone.txt"))
|
||||
await fs.writeFile(path.join(directory, "new.txt"), "hello\nworld\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const status = yield* vcs.status()
|
||||
expect(status).toEqual([
|
||||
{ file: "gone.txt", additions: 0, deletions: 1, status: "deleted" },
|
||||
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
|
||||
{ file: "new.txt", additions: 2, deletions: 0, status: "added" },
|
||||
])
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs the working copy against HEAD with patches", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
|
||||
await commitAll(directory, "initial")
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
|
||||
await fs.writeFile(path.join(directory, "spaced name.txt"), "hello\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "keep.txt", status: "modified" },
|
||||
{ file: "spaced name.txt", status: "added" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("-two")
|
||||
expect(diff[0].patch).toContain("+three")
|
||||
expect(diff[0].additions).toBe(1)
|
||||
expect(diff[0].deletions).toBe(1)
|
||||
expect(diff[1].patch).toContain("+hello")
|
||||
expect(diff[1].additions).toBe(1)
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects the context option", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "file.txt"), body)
|
||||
await commitAll(directory, "initial")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const full = yield* vcs.diff("working")
|
||||
expect(full[0].patch).toContain("line-0")
|
||||
expect(full[0].patch).toContain("line-19")
|
||||
const tight = yield* vcs.diff("working", { context: 1 })
|
||||
expect(tight[0].patch).toContain("line-9")
|
||||
expect(tight[0].patch).not.toContain("line-0")
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs before the first commit", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "new.txt"), "hello\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([{ file: "new.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0].patch).toContain("+hello")
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs a feature branch against the default branch", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git checkout -q -b feature`.cwd(directory).quiet()
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("+two")
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -833,6 +833,25 @@ export interface ProjectCopyApi<E = never> {
|
|||
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint24_0Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
|
||||
export type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] }
|
||||
export type Endpoint24_0Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.status"]>>
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
type Endpoint24_1Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
|
||||
export type Endpoint24_1Input = {
|
||||
readonly location?: Endpoint24_1Request["query"]["location"]
|
||||
readonly mode: Endpoint24_1Request["query"]["mode"]
|
||||
readonly context?: Endpoint24_1Request["query"]["context"]
|
||||
}
|
||||
export type Endpoint24_1Output = EffectValue<ReturnType<RawClient["server.vcs"]["vcs.diff"]>>
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly location: LocationApi<E>
|
||||
|
|
@ -858,4 +877,5 @@ export interface AppApi<E = never> {
|
|||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { McpGroup } from "./groups/mcp.js"
|
|||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
|
|
@ -46,6 +47,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
|||
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
|
||||
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
|
||||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
|
||||
|
|
@ -162,6 +164,7 @@ const makeApiFromGroup = <
|
|||
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const groupNames = {
|
|||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
"server.vcs": "vcs",
|
||||
} as const
|
||||
|
||||
export const endpointNames = {
|
||||
|
|
|
|||
50
packages/protocol/src/groups/vcs.ts
Normal file
50
packages/protocol/src/groups/vcs.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { NonNegativeInt } from "@opencode-ai/schema/schema"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const DiffQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
mode: Vcs.Mode,
|
||||
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||
})
|
||||
|
||||
export const VcsGroup = HttpApiGroup.make("server.vcs")
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Vcs.FileStatus)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.vcs.status",
|
||||
summary: "VCS status",
|
||||
description: "List uncommitted working-copy changes relative to the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.diff", "/api/vcs/diff", {
|
||||
query: DiffQuery,
|
||||
success: Location.response(Schema.Array(FileDiff.Info)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.vcs.diff",
|
||||
summary: "VCS diff",
|
||||
description:
|
||||
"Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "vcs",
|
||||
description: "Location-scoped version control routes.",
|
||||
}),
|
||||
)
|
||||
|
|
@ -17,6 +17,7 @@ export { Provider } from "./provider.js"
|
|||
export { Reference } from "./reference.js"
|
||||
export { Revert } from "./revert.js"
|
||||
export { Session } from "./session.js"
|
||||
export { Vcs } from "./vcs.js"
|
||||
export { SessionInput } from "./session-input.js"
|
||||
export { SessionMessage } from "./session-message.js"
|
||||
export { Shell } from "./shell.js"
|
||||
|
|
|
|||
15
packages/schema/src/vcs.ts
Normal file
15
packages/schema/src/vcs.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "./schema.js"
|
||||
|
||||
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
|
||||
export type Mode = typeof Mode.Type
|
||||
|
||||
export const FileStatus = Schema.Struct({
|
||||
file: Schema.String,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
}).annotate({ identifier: "Vcs.FileStatus" })
|
||||
export interface FileStatus extends Schema.Schema.Type<typeof FileStatus> {}
|
||||
|
|
@ -23,6 +23,7 @@ import { McpHandler } from "./handlers/mcp"
|
|||
import { CredentialHandler } from "./handlers/credential"
|
||||
import { ProjectHandler } from "./handlers/project"
|
||||
import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||
import { VcsHandler } from "./handlers/vcs"
|
||||
|
||||
export const handlers = Layer.mergeAll(
|
||||
HealthHandler,
|
||||
|
|
@ -49,4 +50,5 @@ export const handlers = Layer.mergeAll(
|
|||
QuestionHandler,
|
||||
ReferenceHandler,
|
||||
ProjectCopyHandler,
|
||||
VcsHandler,
|
||||
)
|
||||
|
|
|
|||
27
packages/server/src/handlers/vcs.ts
Normal file
27
packages/server/src/handlers/vcs.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle("vcs.status", () =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.status()
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("vcs.diff", (ctx) =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff(ctx.query.mode, { context: ctx.query.context })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue