Files
anomalyco_opencode/packages/opencode/src/project/vcs.ts
T
2026-03-18 15:49:20 +05:30

369 lines
11 KiB
TypeScript

import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { Effect, Layer, ServiceMap } from "effect"
import path from "path"
const log = Log.create({ service: "vcs" })
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
type Base = { name: string; ref: string }
type Item = { file: string; code: string; status: Snapshot.FileDiff["status"] }
async function mapLimit<T, R>(list: T[], limit: number, fn: (item: T) => Promise<R>) {
const size = Math.max(1, limit)
const out: R[] = new Array(list.length)
let idx = 0
await Promise.all(
Array.from({ length: Math.min(size, list.length) }, async () => {
while (true) {
const i = idx
idx += 1
if (i >= list.length) return
out[i] = await fn(list[i]!)
}
}),
)
return out
}
function out(result: { text(): string }) {
return result.text().trim()
}
async function run(cwd: string, args: string[]) {
return git([...cfg, ...args], { cwd })
}
async function branch(cwd: string) {
const result = await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
}
async function prefix(cwd: string) {
const result = await run(cwd, ["rev-parse", "--show-prefix"])
if (result.exitCode !== 0) return ""
return out(result)
}
async function branches(cwd: string) {
const result = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
if (result.exitCode !== 0) return []
return out(result)
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
}
async function configured(cwd: string, list: string[]) {
const result = await run(cwd, ["config", "init.defaultBranch"])
if (result.exitCode !== 0) return
const name = out(result)
if (!name || !list.includes(name)) return
const ref = await run(cwd, ["rev-parse", "--verify", name])
if (ref.exitCode !== 0) return
return { name, ref: name } satisfies Base
}
async function remoteHead(cwd: string, remote: string) {
const result = await run(cwd, ["ls-remote", "--symref", remote, "HEAD"])
if (result.exitCode !== 0) return
for (const line of result.text().split("\n")) {
const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim())
if (!match?.[1]) continue
return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base
}
}
async function primary(cwd: string) {
const result = await run(cwd, ["remote"])
const list =
result.exitCode !== 0
? []
: out(result)
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
if (list.includes("origin")) return "origin"
if (list.length === 1) return list[0]
if (list.includes("upstream")) return "upstream"
return list[0]
}
async function base(cwd: string) {
const remote = await primary(cwd)
if (remote) {
const head = await run(cwd, ["symbolic-ref", `refs/remotes/${remote}/HEAD`])
if (head.exitCode === 0) {
const ref = out(head).replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
}
const next = await remoteHead(cwd, remote)
if (next) return next
}
const list = await branches(cwd)
const next = await configured(cwd, list)
if (next) return next
for (const name of ["main", "master"]) {
if (list.includes(name)) return { name, ref: name }
}
}
async function head(cwd: string) {
const result = await run(cwd, ["rev-parse", "--verify", "HEAD"])
return result.exitCode === 0
}
async function work(cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(await Filesystem.exists(full))) return ""
const buf = await Filesystem.readBytes(full).catch(() => Buffer.alloc(0))
if (buf.includes(0)) return ""
return buf.toString("utf8")
}
async function show(cwd: string, ref: string | undefined, file: string, base: string) {
if (!ref) return ""
const target = base ? `${base}${file}` : file
const result = await run(cwd, ["show", `${ref}:${target}`])
if (result.exitCode !== 0) return ""
return result.text()
}
function kind(code: string | undefined): "added" | "deleted" | "modified" {
if (code === "??") return "added"
if (code?.includes("U")) return "modified"
if (code?.includes("A") && !code.includes("D")) return "added"
if (code?.includes("D") && !code.includes("A")) return "deleted"
return "modified"
}
function count(text: string) {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
function split(text: string) {
return text.split("\0").filter(Boolean)
}
function parseStatus(text: string) {
return split(text).flatMap((item) => {
const file = item.slice(3)
if (!file) return []
const code = item.slice(0, 2)
return [{ file, code, status: kind(code) } satisfies Item]
})
}
function parseNames(text: string) {
const list = split(text)
const out: Item[] = []
for (let i = 0; i < list.length; i += 2) {
const code = list[i]
const file = list[i + 1]
if (!code || !file) continue
out.push({ file, code, status: kind(code) })
}
return out
}
function parseNums(text: string) {
const out = new Map<string, { additions: number; deletions: number }>()
for (const item of split(text)) {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
if (a === -1 || b === -1) continue
const file = item.slice(b + 1)
if (!file) continue
const adds = item.slice(0, a)
const dels = item.slice(a + 1, b)
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
out.set(file, {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
})
}
return out
}
function merge(...lists: Item[][]) {
const out = new Map<string, Item>()
for (const list of lists) {
for (const item of list) {
if (!out.has(item.file)) out.set(item.file, item)
}
}
return [...out.values()]
}
async function files(cwd: string, ref: string | undefined, list: Item[], nums: Map<string, { additions: number; deletions: number }>) {
const base = ref ? await prefix(cwd) : ""
const next = await mapLimit(list, 8, async (item) => {
const before = item.status === "added" ? "" : await show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : await work(cwd, item.file)
const stat = nums.get(item.file)
return {
file: item.file,
before,
after,
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.FileDiff
})
return next.toSorted((a, b) => a.file.localeCompare(b.file))
}
async function status(cwd: string) {
const result = await run(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."])
return parseStatus(result.text())
}
async function stats(cwd: string, ref: string) {
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."])
return parseNums(result.text())
}
async function diff(cwd: string, ref: string) {
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."])
return parseNames(result.text())
}
async function track(cwd: string, ref: string | undefined) {
const [list, nums] = ref ? await Promise.all([status(cwd), stats(cwd, ref)]) : [await status(cwd), new Map()]
return files(cwd, ref, list, nums)
}
async function compare(cwd: string, ref: string) {
const [list, nums, extra] = await Promise.all([diff(cwd, ref), stats(cwd, ref), status(cwd)])
return files(
cwd,
ref,
merge(
list,
extra.filter((item) => item.code === "??"),
),
nums,
)
}
export namespace Vcs {
export const Mode = z.enum(["git", "branch"])
export type Mode = z.infer<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
z.object({
branch: z.string().optional(),
}),
),
}
export const Info = z
.object({
branch: z.string().optional(),
default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
})
export type Info = z.infer<typeof Info>
}
export namespace VcsService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Vcs.Mode) => Effect.Effect<Snapshot.FileDiff[]>
}
}
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
static readonly layer = Layer.effect(
VcsService,
Effect.gen(function* () {
const instance = yield* InstanceContext
let current: string | undefined
let root: Base | undefined
if (instance.project.vcs === "git") {
const currentBranch = async () => {
return branch(instance.directory)
}
;[current, root] = yield* Effect.promise(() => Promise.all([currentBranch(), base(instance.directory)]))
log.info("initialized", { branch: current, default_branch: root?.name })
const unsubscribe = Bus.subscribe(
FileWatcher.Event.Updated,
Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch()
if (next !== current) {
log.info("branch changed", { from: current, to: next })
current = next
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
}
}),
)
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
}
return VcsService.of({
init: Effect.fn("VcsService.init")(function* () {}),
branch: Effect.fn("VcsService.branch")(function* () {
return current
}),
defaultBranch: Effect.fn("VcsService.defaultBranch")(function* () {
return root?.name
}),
diff: Effect.fn("VcsService.diff")(function* (mode: Vcs.Mode) {
if (instance.project.vcs !== "git") return []
if (mode === "git") {
const ok = yield* Effect.promise(() => head(instance.directory))
return yield* Effect.promise(() => track(instance.directory, ok ? "HEAD" : undefined))
}
if (!root) return []
if (current && current === root.name) return []
const ref = yield* Effect.promise(() => run(instance.directory, ["merge-base", root.ref, "HEAD"]))
if (ref.exitCode !== 0) return []
const text = out(ref)
if (!text) return []
return yield* Effect.promise(() => compare(instance.directory, text))
}),
})
}),
)
}