feat(core): add mercurial adapter for vcs status and diff (#35141)

This commit is contained in:
Shoubhit Dash 2026-07-03 17:42:25 +05:30 committed by GitHub
parent 8d790a9ed5
commit 5657cec4f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 769 additions and 99 deletions

View file

@ -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],
})

View file

@ -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

View file

@ -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],
})

View file

@ -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
View 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 }
}

View 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>())

View file

@ -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(

View 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)),
),
)
})

View file

@ -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,

View file

@ -744,7 +744,7 @@ export type Project = {
id: string
worktree: string
vcsDir?: string
vcs?: "git"
vcs?: "git" | "hg"
time: {
created: number
initialized?: number

View file

@ -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 {

View file

@ -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: {