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