diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5df..5ff1fc121c 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -40,7 +40,7 @@ function lookup(_key: string) { Layer.fresh(PermissionNext.layer), Layer.fresh(ProviderAuth.defaultLayer), Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.layer), + Layer.fresh(Vcs.defaultLayer), Layer.fresh(FileTime.layer).pipe(Layer.orDie), Layer.fresh(Format.layer), Layer.fresh(File.layer), diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9c8f38336a..2809e3678c 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -3,138 +3,18 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" +import { Git } from "@/git" import { Snapshot } from "@/snapshot" import { Filesystem } from "@/util/filesystem" import { Log } from "@/util/log" -import { git } from "@/util/git" import path from "path" import { Instance } from "./instance" import z from "zod" -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(list: T[], limit: number, fn: (item: T) => Promise) { - 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 +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 } async function work(cwd: string, file: string) { @@ -145,75 +25,19 @@ async function work(cwd: string, file: string) { 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) { +function stats(list: Git.Stat[]) { const out = new Map() - 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, + for (const item of list) { + out.set(item.file, { + additions: item.additions, + deletions: item.deletions, }) } return out } -function merge(...lists: Item[][]) { - const out = new Map() +function merge(...lists: Git.Item[][]) { + const out = new Map() for (const list of lists) { for (const item of list) { if (!out.has(item.file)) out.set(item.file, item) @@ -222,56 +46,56 @@ function merge(...lists: Item[][]) { return [...out.values()] } -async function files(cwd: string, ref: string | undefined, list: Item[], nums: Map) { - 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 - }) +const files = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + list: Git.Item[], + nums: Map, +) { + const base = ref ? yield* git.prefix(cwd) : "" + const next = yield* Effect.all( + list.map((item) => + Effect.gen(function* () { + const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) + const after = item.status === "deleted" ? "" : yield* Effect.promise(() => 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 + }), + ), + { concurrency: 8 }, + ) 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()) -} +const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { + if (!ref) { + return yield* files(git, cwd, ref, yield* git.status(cwd), new Map()) + } + const [list, nums] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)]) + return yield* files(git, cwd, ref, list, stats(nums)) +}) -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( +const compare = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { + const [list, nums, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)]) + return yield* files( + git, cwd, ref, merge( list, extra.filter((item) => item.code === "??"), ), - nums, + stats(nums), ) -} +}) export namespace Vcs { const log = Log.create({ service: "vcs" }) @@ -310,13 +134,14 @@ export namespace Vcs { Service, Effect.gen(function* () { const instance = yield* InstanceContext + const git = yield* Git.Service let current: string | undefined - let root: Base | undefined + let root: Git.Base | undefined if (instance.project.vcs === "git") { - const get = () => branch(instance.directory) + const get = () => Effect.runPromise(git.branch(instance.directory)) - ;[current, root] = yield* Effect.promise(() => Promise.all([get(), base(instance.directory)])) + ;[current, root] = yield* Effect.all([git.branch(instance.directory), git.defaultBranch(instance.directory)]) log.info("initialized", { branch: current, default_branch: root?.name }) yield* Effect.acquireRelease( @@ -347,19 +172,19 @@ export namespace Vcs { diff: Effect.fn("Vcs.diff")(function* (mode: 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)) + const ok = yield* git.hasHead(instance.directory) + return yield* track(git, 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)) + const ref = yield* git.mergeBase(instance.directory, root.ref) + if (!ref) return [] + return yield* compare(git, instance.directory, ref) }), }) }), ) + + export const defaultLayer = layer.pipe(Layer.provide(Git.layer)) } diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 8fd9621360..d92e2c39a8 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -23,7 +23,7 @@ function withVcs( ) { return withServices( directory, - Layer.merge(FileWatcher.layer, Vcs.layer), + Layer.merge(FileWatcher.layer, Vcs.defaultLayer), async (rt) => { await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(Vcs.Service.use(() => Effect.void)) @@ -35,7 +35,7 @@ function withVcs( } function withVcsOnly(directory: string, body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { - return withServices(directory, Vcs.layer, body) + return withServices(directory, Vcs.defaultLayer, body) } type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }