From 5657cec4f252e5cf736a63ba606342337bfa828d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 3 Jul 2026 17:42:25 +0530 Subject: [PATCH] feat(core): add mercurial adapter for vcs status and diff (#35141) --- packages/core/src/project.ts | 66 ++++++-- packages/core/src/project/schema.ts | 4 + packages/core/src/vcs.ts | 18 ++- packages/core/src/vcs/git.ts | 85 +---------- packages/core/src/vcs/hg.ts | 192 ++++++++++++++++++++++++ packages/core/src/vcs/patch.ts | 106 +++++++++++++ packages/core/test/project.test.ts | 58 +++++++ packages/core/test/vcs-hg.test.ts | 170 +++++++++++++++++++++ packages/schema/src/project.ts | 2 +- packages/sdk/js/src/gen/types.gen.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 69 +++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 96 +++++++++++- 12 files changed, 769 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/vcs/hg.ts create mode 100644 packages/core/src/vcs/patch.ts create mode 100644 packages/core/test/vcs-hg.test.ts diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index e87180b75a..fe15f11511 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -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], }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 5866551e4e..86c57e17ca 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -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 diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index 1a108db0b1..46417eafb7 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -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()("@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], +}) diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts index 5da546b39b..d416a5af8a 100644 --- a/packages/core/src/vcs/git.ts +++ b/packages/core/src/vcs/git.ts @@ -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(), 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()), + patches: chunksByFile(result, (index) => list[index]?.file), capped: result.truncated, } }) diff --git a/packages/core/src/vcs/hg.ts b/packages/core/src/vcs/hg.ts new file mode 100644 index 0000000000..0c27f12b95 --- /dev/null +++ b/packages/core/src/vcs/hg.ts @@ -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 } +} diff --git a/packages/core/src/vcs/patch.ts b/packages/core/src/vcs/patch.ts new file mode 100644 index 0000000000..73d3f35dd9 --- /dev/null +++ b/packages/core/src/vcs/patch.ts @@ -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()) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index fa709a8b2b..ee1264f887 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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( diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts new file mode 100644 index 0000000000..372631cb1f --- /dev/null +++ b/packages/core/test/vcs-hg.test.ts @@ -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 = (f: (directory: string) => Effect.Effect) => + 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)), + ), + ) +}) diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index cd4347826d..fce8d19ebf 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -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, diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5e4fd89061..1055d82ce2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -744,7 +744,7 @@ export type Project = { id: string worktree: string vcsDir?: string - vcs?: "git" + vcs?: "git" | "hg" time: { created: number initialized?: number diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 554c31dfb2..1c896d80a3 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + 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( + parameters: { + location?: { + directory?: string | null + workspace?: string | null + } | null + mode: VcsMode2 + context?: string | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "mode" }, + { in: "query", key: "context" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + 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 { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0aa1dbeb22..13fbacae2b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + } +} + +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 + } +} + +export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] + export type PtyConnectData = { body?: never path: {