mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 02:58:29 +00:00
feat(core): add mercurial adapter for vcs status and diff (#35141)
This commit is contained in:
parent
8d790a9ed5
commit
5657cec4f2
12 changed files with 769 additions and 99 deletions
|
|
@ -2,10 +2,12 @@ export * as ProjectV2 from "./project"
|
|||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { AppProcess } from "./process"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { Hash } from "./util/hash"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
|
|
@ -45,7 +47,7 @@ export const root = Effect.fn("Project.root")(function* (
|
|||
fs: FSUtil.Interface,
|
||||
input: AbsolutePath,
|
||||
) {
|
||||
return yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
|
|
@ -73,6 +75,7 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
|
|
@ -124,20 +127,65 @@ const layer = Layer.effect(
|
|||
return root ? ID.make(root) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
// Mercurial identity uses the cached ID or the first root changeset; remote-derived
|
||||
// identity (the git `remote()` path) is a follow-up.
|
||||
const hgRoot = Effect.fnUntraced(function* (worktree: AbsolutePath) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("hg", ["log", "-r", "roots(all())", "-T", "{node}\n"], {
|
||||
cwd: worktree,
|
||||
env: { HGPLAIN: "1" },
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!result || result.exitCode !== 0) return undefined
|
||||
const node = result.stdout
|
||||
.toString("utf8")
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted()[0]
|
||||
return node ? ID.make(node) : undefined
|
||||
})
|
||||
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!dotHg) return undefined
|
||||
const worktree = AbsolutePath.make(path.dirname(dotHg))
|
||||
const store = AbsolutePath.make(dotHg)
|
||||
const previous = yield* cached(store)
|
||||
const id = previous ?? (yield* hgRoot(worktree))
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
directory: worktree,
|
||||
vcs: { type: "hg" as const, store },
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
}
|
||||
}
|
||||
|
||||
const hg = yield* hgDiscover(input)
|
||||
if (hg) return hg
|
||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
|
@ -149,5 +197,5 @@ const layer = Layer.effect(
|
|||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||
deps: [FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,5 +24,9 @@ export const Vcs = Schema.Union([
|
|||
type: Schema.Literal("git"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("hg"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
])
|
||||
export type Vcs = typeof Vcs.Type
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ 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 { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "./process"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
export { FileStatus, Mode }
|
||||
|
||||
|
|
@ -24,17 +26,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
// 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 adapter = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||
const scope = { directory: location.directory, worktree: location.project.directory }
|
||||
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
||||
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const proc = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const impl = adapter(proc, location)
|
||||
const impl = adapter(proc, fs, location)
|
||||
return Service.of({
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
if (!impl) return []
|
||||
|
|
@ -48,4 +52,8 @@ const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: layer, deps: [AppProcess.node, Location.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
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
|
||||
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
|
||||
import type { Patch } from "./patch"
|
||||
|
||||
/**
|
||||
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
|
||||
|
|
@ -84,11 +81,6 @@ interface Stat {
|
|||
readonly deletions: number
|
||||
}
|
||||
|
||||
interface Patch {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
interface PatchOptions {
|
||||
readonly context?: number
|
||||
readonly maxOutputBytes?: number
|
||||
|
|
@ -325,8 +317,6 @@ function makeGit(proc: AppProcess.Interface) {
|
|||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
|
|
@ -340,70 +330,6 @@ const merge = (...lists: Item[][]) => {
|
|||
|
||||
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()
|
||||
|
||||
|
|
@ -413,12 +339,7 @@ const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: I
|
|||
})
|
||||
|
||||
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>()),
|
||||
patches: chunksByFile(result, (index) => list[index]?.file),
|
||||
capped: result.truncated,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
192
packages/core/src/vcs/hg.ts
Normal file
192
packages/core/src/vcs/hg.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
export * as VcsHg from "./hg"
|
||||
|
||||
import path from "path"
|
||||
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 { FSUtil } from "../fs-util"
|
||||
import { AppProcess } from "../process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
import {
|
||||
addPatch,
|
||||
chunksByFile,
|
||||
countPatch,
|
||||
deletePatch,
|
||||
emptyPatch,
|
||||
MAX_PATCH_BYTES,
|
||||
MAX_TOTAL_PATCH_BYTES,
|
||||
PATCH_CONTEXT_LINES,
|
||||
} from "./patch"
|
||||
|
||||
/**
|
||||
* Mercurial adapter for the Vcs service. `hg diff --git` emits git-format
|
||||
* patches for tracked changes; untracked (`?`) and missing (`!`) files never
|
||||
* appear in `hg diff`, so their patches are synthesized from file contents.
|
||||
*/
|
||||
export function make(
|
||||
proc: AppProcess.Interface,
|
||||
fs: FSUtil.Interface,
|
||||
input: { directory: string; worktree: string },
|
||||
): Interface {
|
||||
const hg = makeHg(proc, input.worktree)
|
||||
// All commands run from the worktree root (hg prints root-relative paths);
|
||||
// this pathspec scopes them to the requested directory.
|
||||
const scope = path.relative(input.worktree, input.directory) || "."
|
||||
|
||||
const patchFor = Effect.fnUntraced(function* (item: Item, rev: string | undefined, chunk: string | undefined) {
|
||||
if (chunk !== undefined) return chunk
|
||||
if (item.code === "?") {
|
||||
const content = yield* fs
|
||||
.readFileString(path.join(input.worktree, item.file))
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (content === undefined || Buffer.byteLength(content) > MAX_PATCH_BYTES) return emptyPatch(item.file)
|
||||
return addPatch(item.file, content)
|
||||
}
|
||||
if (item.code === "!") {
|
||||
const content = yield* hg.cat(rev ?? ".", item.file)
|
||||
if (content === undefined || Buffer.byteLength(content) > MAX_PATCH_BYTES) return emptyPatch(item.file)
|
||||
return deletePatch(item.file, content)
|
||||
}
|
||||
return emptyPatch(item.file)
|
||||
})
|
||||
|
||||
const diffAgainst = Effect.fnUntraced(function* (rev: string | undefined, options?: DiffOptions) {
|
||||
const [items, batch] = yield* Effect.all(
|
||||
[hg.status(rev, scope), hg.diff(rev, scope, { context: options?.context ?? PATCH_CONTEXT_LINES })],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const chunks = chunksByFile(batch, () => undefined)
|
||||
const list: FileDiff.Info[] = []
|
||||
for (const item of items.toSorted((a, b) => a.file.localeCompare(b.file))) {
|
||||
const patch = yield* patchFor(item, rev, chunks.get(item.file))
|
||||
const counts = countPatch(patch)
|
||||
list.push({
|
||||
file: item.file,
|
||||
patch,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: item.status,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
return {
|
||||
status: Effect.fn("VcsHg.status")(function* () {
|
||||
const [items, batch] = yield* Effect.all(
|
||||
// Zero-context patches are enough to count changed lines.
|
||||
[hg.status(undefined, scope), hg.diff(undefined, scope, { context: 0 })],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const chunks = chunksByFile(batch, () => undefined)
|
||||
return yield* Effect.forEach(
|
||||
items.toSorted((a, b) => a.file.localeCompare(b.file)),
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const patch = yield* patchFor(item, undefined, chunks.get(item.file))
|
||||
const counts = countPatch(patch)
|
||||
return {
|
||||
file: item.file,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: item.status,
|
||||
} satisfies FileStatus
|
||||
}),
|
||||
)
|
||||
}),
|
||||
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
if (mode === "working") return yield* diffAgainst(undefined, options)
|
||||
|
||||
const branch = yield* hg.branch()
|
||||
if (!branch || branch === "default") return []
|
||||
const ancestor = yield* hg.ancestor()
|
||||
if (!ancestor) return []
|
||||
return yield* diffAgainst(ancestor, options)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type Kind = FileStatus["status"]
|
||||
|
||||
interface Item {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
readonly status: Kind
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
if (code === "A" || code === "?") return "added"
|
||||
if (code === "R" || code === "!") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function makeHg(proc: AppProcess.Interface, worktree: string) {
|
||||
const run = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { maxOutputBytes?: number }) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("hg", args, {
|
||||
cwd: worktree,
|
||||
env: { HGPLAIN: "1" },
|
||||
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 status = Effect.fn("VcsHg.statusNames")(function* (rev: string | undefined, scope: string) {
|
||||
const result = yield* run(["status", "-0", ...(rev ? ["--rev", rev] : []), scope])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result
|
||||
.text()
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.flatMap((entry) => {
|
||||
const code = entry.slice(0, 1)
|
||||
const file = entry.slice(2)
|
||||
if (!file) return []
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const diff = Effect.fn("VcsHg.diffPatch")(function* (
|
||||
rev: string | undefined,
|
||||
scope: string,
|
||||
options: { context: number },
|
||||
) {
|
||||
const result = yield* run(
|
||||
["diff", "--git", "--unified", String(options.context), ...(rev ? ["-r", rev] : []), scope],
|
||||
{ maxOutputBytes: MAX_TOTAL_PATCH_BYTES },
|
||||
)
|
||||
if (result.exitCode !== 0) return { text: "", truncated: false }
|
||||
return { text: result.text(), truncated: result.truncated }
|
||||
})
|
||||
|
||||
const branch = Effect.fn("VcsHg.branch")(function* () {
|
||||
const result = yield* run(["branch"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const ancestor = Effect.fn("VcsHg.ancestor")(function* () {
|
||||
const result = yield* run(["log", "-r", "ancestor(., default)", "-T", "{node}"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const cat = Effect.fn("VcsHg.cat")(function* (rev: string, file: string) {
|
||||
const result = yield* run(["cat", "-r", rev, "--", file], { maxOutputBytes: MAX_PATCH_BYTES })
|
||||
if (result.exitCode !== 0 || result.truncated) return undefined
|
||||
return result.text()
|
||||
})
|
||||
|
||||
return { status, diff, branch, ancestor, cat }
|
||||
}
|
||||
106
packages/core/src/vcs/patch.ts
Normal file
106
packages/core/src/vcs/patch.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
export * as VcsPatch from "./patch"
|
||||
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
|
||||
// Effectively "full file" context; adapters use it when no context is requested.
|
||||
export const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
export const MAX_PATCH_BYTES = 10_000_000
|
||||
export const MAX_TOTAL_PATCH_BYTES = 10_000_000
|
||||
|
||||
export interface Patch {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
|
||||
|
||||
export const addPatch = (file: string, content: string) =>
|
||||
formatPatch(structuredPatch(file, file, "", content, "", "", { context: 0 }))
|
||||
|
||||
export const deletePatch = (file: string, content: string) =>
|
||||
formatPatch(structuredPatch(file, file, content, "", "", "", { context: 0 }))
|
||||
|
||||
/** Count changed lines in a unified diff, ignoring `+++`/`---` file headers. */
|
||||
export const countPatch = (patch: string) => {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions++
|
||||
else if (line.startsWith("-") && !line.startsWith("---")) deletions++
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
export 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 ?? "")
|
||||
}
|
||||
|
||||
/** Split `git diff`-format output into per-file chunks, dropping a trailing partial chunk when truncated. */
|
||||
export 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)
|
||||
}
|
||||
|
||||
/** Map per-file chunks by the file they touch, concatenating chunks for the same file. */
|
||||
export const chunksByFile = (patch: Patch, fallback: (index: number) => string | undefined) =>
|
||||
splitGitPatch(patch).reduce((acc, chunk, index) => {
|
||||
const file = fileFromPatchChunk(chunk) ?? fallback(index)
|
||||
if (!file) return acc
|
||||
acc.set(file, (acc.get(file) ?? "") + chunk)
|
||||
return acc
|
||||
}, new Map<string, string>())
|
||||
|
|
@ -195,6 +195,64 @@ describe("ProjectV2.resolve", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const itHg = Bun.which("hg") ? it : { live: it.live.skip }
|
||||
|
||||
itHg.live("detects mercurial repositories from nested directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await $`hg init`.cwd(tmp.path).quiet()
|
||||
await Bun.write(path.join(tmp.path, "file.txt"), "one\n")
|
||||
await $`hg addremove -q`.cwd(tmp.path).env({ ...process.env, HGPLAIN: "1" }).quiet()
|
||||
await $`hg commit -q -m initial -u test`.cwd(tmp.path).env({ ...process.env, HGPLAIN: "1" }).quiet()
|
||||
await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
|
||||
})
|
||||
const project = yield* ProjectV2.Service
|
||||
|
||||
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||
|
||||
expect(result.vcs?.type).toBe("hg")
|
||||
expect(result.directory).toBe(abs(tmp.path))
|
||||
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
|
||||
expect(result.previous).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers git when both git and mercurial metadata exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
|
||||
const project = yield* ProjectV2.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns global id for unreadable mercurial metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
|
||||
const project = yield* ProjectV2.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.vcs?.type).toBe("hg")
|
||||
expect(result.id).toBe(ProjectV2.ID.make("global"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
|
|
|
|||
170
packages/core/test/vcs-hg.test.ts
Normal file
170
packages/core/test/vcs-hg.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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 describeHg = Bun.which("hg") ? describe : describe.skip
|
||||
|
||||
const provide = (directory: string) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ vcs: { type: "hg", store: AbsolutePath.make(path.join(directory, ".hg")) } },
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
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 hg(directory: string, ...args: string[]) {
|
||||
await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet()
|
||||
}
|
||||
|
||||
async function commitAll(directory: string, message: string) {
|
||||
await hg(directory, "addremove", "-q")
|
||||
await hg(directory, "commit", "-q", "-m", message, "-u", "test")
|
||||
}
|
||||
|
||||
describeHg("Vcs mercurial", () => {
|
||||
it.live("reports modified, missing, and untracked files", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "init")
|
||||
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)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs the working copy with synthesized untracked and missing patches", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "init")
|
||||
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, "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: "gone.txt", status: "deleted" },
|
||||
{ file: "keep.txt", status: "modified" },
|
||||
{ file: "spaced name.txt", status: "added" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("-bye")
|
||||
expect(diff[1].patch).toContain("-two")
|
||||
expect(diff[1].patch).toContain("+three")
|
||||
expect(diff[1].additions).toBe(1)
|
||||
expect(diff[1].deletions).toBe(1)
|
||||
expect(diff[2].patch).toContain("+hello")
|
||||
expect(diff[2].additions).toBe(1)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
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 hg(directory, "init")
|
||||
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)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs before the first commit", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "init")
|
||||
await fs.writeFile(path.join(directory, "tracked.txt"), "a\nb\n")
|
||||
await hg(directory, "add", "-q", "tracked.txt")
|
||||
await fs.writeFile(path.join(directory, "loose.txt"), "hello\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([
|
||||
{ file: "loose.txt", additions: 1, deletions: 0, status: "added" },
|
||||
{ file: "tracked.txt", additions: 2, deletions: 0, status: "added" },
|
||||
])
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff).toHaveLength(2)
|
||||
expect(diff[0].patch).toContain("+hello")
|
||||
expect(diff[1].patch).toContain("+a")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs a named branch against the default branch", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "init")
|
||||
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 hg(directory, "branch", "-q", "feature")
|
||||
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)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -8,7 +8,7 @@ import { ProjectID } from "./project-id.js"
|
|||
export const ID = ProjectID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Vcs = Schema.Literal("git").annotate({ identifier: "Project.Vcs" })
|
||||
export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Project.Vcs" })
|
||||
export const Current = Schema.Struct({
|
||||
id: ID,
|
||||
directory: AbsolutePath,
|
||||
|
|
|
|||
|
|
@ -744,7 +744,7 @@ export type Project = {
|
|||
id: string
|
||||
worktree: string
|
||||
vcsDir?: string
|
||||
vcs?: "git"
|
||||
vcs?: "git" | "hg"
|
||||
time: {
|
||||
created: number
|
||||
initialized?: number
|
||||
|
|
|
|||
|
|
@ -444,6 +444,10 @@ import type {
|
|||
V2ShellRemoveResponses,
|
||||
V2SkillListErrors,
|
||||
V2SkillListResponses,
|
||||
V2VcsDiffErrors,
|
||||
V2VcsDiffResponses,
|
||||
V2VcsStatusErrors,
|
||||
V2VcsStatusResponses,
|
||||
VcsApplyErrors,
|
||||
VcsApplyResponses,
|
||||
VcsDiffErrors,
|
||||
|
|
@ -452,6 +456,7 @@ import type {
|
|||
VcsDiffResponses,
|
||||
VcsGetErrors,
|
||||
VcsGetResponses,
|
||||
VcsMode2,
|
||||
VcsStatusErrors,
|
||||
VcsStatusResponses,
|
||||
WorktreeCreateErrors,
|
||||
|
|
@ -7933,6 +7938,65 @@ export class ProjectCopy2 extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Vcs2 extends HeyApiClient {
|
||||
/**
|
||||
* VCS status
|
||||
*
|
||||
* List uncommitted working-copy changes relative to the requested location.
|
||||
*/
|
||||
public status<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<V2VcsStatusResponses, V2VcsStatusErrors, ThrowOnError>({
|
||||
url: "/api/vcs/status",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* VCS diff
|
||||
*
|
||||
* Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.
|
||||
*/
|
||||
public diff<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
mode: VcsMode2
|
||||
context?: string | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "query", key: "mode" },
|
||||
{ in: "query", key: "context" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<V2VcsDiffResponses, V2VcsDiffErrors, ThrowOnError>({
|
||||
url: "/api/vcs/diff",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class V2 extends HeyApiClient {
|
||||
private _health?: Health
|
||||
get health(): Health {
|
||||
|
|
@ -8048,6 +8112,11 @@ export class V2 extends HeyApiClient {
|
|||
get projectCopy(): ProjectCopy2 {
|
||||
return (this._projectCopy ??= new ProjectCopy2({ client: this.client }))
|
||||
}
|
||||
|
||||
private _vcs?: Vcs2
|
||||
get vcs(): Vcs2 {
|
||||
return (this._vcs ??= new Vcs2({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class OpencodeClient extends HeyApiClient {
|
||||
|
|
|
|||
|
|
@ -3194,6 +3194,13 @@ export type ProjectCopyError = {
|
|||
}
|
||||
}
|
||||
|
||||
export type VcsFileStatus2 = {
|
||||
file: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type EffectHttpApiErrorForbidden = {
|
||||
_tag: "Forbidden"
|
||||
}
|
||||
|
|
@ -3533,7 +3540,7 @@ export type FormAnswer = {
|
|||
[key: string]: FormValue
|
||||
}
|
||||
|
||||
export type ProjectVcs = "git"
|
||||
export type ProjectVcs = "git" | "hg"
|
||||
|
||||
export type ProjectIcon = {
|
||||
url?: string
|
||||
|
|
@ -6993,6 +7000,8 @@ export type ProjectCopyCopy = {
|
|||
directory: string
|
||||
}
|
||||
|
||||
export type VcsMode = "working" | "branch"
|
||||
|
||||
export type EventModelsDevRefreshed = {
|
||||
id: string
|
||||
type: "models-dev.refreshed"
|
||||
|
|
@ -11913,6 +11922,15 @@ export type ProjectCopyErrorV2 = {
|
|||
}
|
||||
}
|
||||
|
||||
export type VcsFileStatusV2 = {
|
||||
file: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type VcsMode2 = "working" | "branch"
|
||||
|
||||
export type AuthRemoveData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
|
@ -19512,6 +19530,82 @@ export type V2ProjectCopyRefreshResponses = {
|
|||
|
||||
export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses]
|
||||
|
||||
export type V2VcsStatusData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/vcs/status"
|
||||
}
|
||||
|
||||
export type V2VcsStatusErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedErrorV2
|
||||
}
|
||||
|
||||
export type V2VcsStatusError = V2VcsStatusErrors[keyof V2VcsStatusErrors]
|
||||
|
||||
export type V2VcsStatusResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfo2
|
||||
data: Array<VcsFileStatusV2>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2VcsStatusResponse = V2VcsStatusResponses[keyof V2VcsStatusResponses]
|
||||
|
||||
export type V2VcsDiffData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
mode: VcsMode2
|
||||
context?: string | null
|
||||
}
|
||||
url: "/api/vcs/diff"
|
||||
}
|
||||
|
||||
export type V2VcsDiffErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedErrorV2
|
||||
}
|
||||
|
||||
export type V2VcsDiffError = V2VcsDiffErrors[keyof V2VcsDiffErrors]
|
||||
|
||||
export type V2VcsDiffResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfo2
|
||||
data: Array<SnapshotFileDiffV2>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses]
|
||||
|
||||
export type PtyConnectData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue