192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
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 }
|
|
}
|