diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index dea25b91b4..e3243ba8eb 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,17 +1,111 @@ import { Effect, Layer, ServiceMap } from "effect" +import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" import { FileWatcher } from "@/file/watcher" +import { Git } from "@/git" +import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -import { git } from "@/util/git" import { Instance } from "./instance" import z from "zod" export namespace Vcs { const log = Log.create({ service: "vcs" }) + const count = (text: string) => { + if (!text) return 0 + if (!text.endsWith("\n")) return text.split("\n").length + return text.slice(0, -1).split("\n").length + } + + const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { + const full = path.join(cwd, file) + if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" + const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) + if (Buffer.from(buf).includes(0)) return "" + return Buffer.from(buf).toString("utf8") + }) + + const nums = (list: Git.Stat[]) => + new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) + + const merge = (...lists: Git.Item[][]) => { + const out = new Map() + lists.flat().forEach((item) => { + if (!out.has(item.file)) out.set(item.file, item) + }) + return [...out.values()] + } + + const files = Effect.fnUntraced(function* ( + fs: AppFileSystem.Interface, + git: Git.Interface, + cwd: string, + ref: string | undefined, + list: Git.Item[], + map: Map, + ) { + const base = ref ? yield* git.prefix(cwd) : "" + const next = yield* Effect.forEach( + list, + (item) => + Effect.gen(function* () { + const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) + const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) + const stat = map.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)) + }) + + const track = Effect.fnUntraced(function* ( + fs: AppFileSystem.Interface, + git: Git.Interface, + cwd: string, + ref: string | undefined, + ) { + if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) + const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) + return yield* files(fs, git, cwd, ref, list, nums(stats)) + }) + + const compare = Effect.fnUntraced(function* ( + fs: AppFileSystem.Interface, + git: Git.Interface, + cwd: string, + ref: string, + ) { + const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { + concurrency: 3, + }) + return yield* files( + fs, + git, + cwd, + ref, + merge( + list, + extra.filter((item) => item.code === "??"), + ), + nums(stats), + ) + }) + + export const Mode = z.enum(["git", "branch"]) + export type Mode = z.infer + export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", @@ -23,7 +117,8 @@ export namespace Vcs { export const Info = z .object({ - branch: z.string(), + branch: z.string().optional(), + default_branch: z.string().optional(), }) .meta({ ref: "VcsInfo", @@ -33,37 +128,35 @@ export namespace Vcs { export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect + readonly defaultBranch: () => Effect.Effect + readonly diff: (mode: Mode) => Effect.Effect } interface State { current: string | undefined + root: Git.Base | undefined } export class Service extends ServiceMap.Service()("@opencode/Vcs") {} - export const layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("Vcs.state")((ctx) => Effect.gen(function* () { if (ctx.project.vcs !== "git") { - return { current: undefined } + return { current: undefined, root: undefined } } - const getCurrentBranch = async () => { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: ctx.worktree, - }) - if (result.exitCode !== 0) return undefined - const text = result.text().trim() - return text || undefined - } - - const value = { - current: yield* Effect.promise(() => getCurrentBranch()), - } - log.info("initialized", { branch: value.current }) + const get = () => Effect.runPromise(git.branch(ctx.directory)) + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + const value = { current, root } + log.info("initialized", { branch: value.current, default_branch: value.root?.name }) yield* Effect.acquireRelease( Effect.sync(() => @@ -71,12 +164,11 @@ export namespace Vcs { FileWatcher.Event.Updated, Instance.bind(async (evt) => { if (!evt.properties.file.endsWith("HEAD")) return - const next = await getCurrentBranch() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - Bus.publish(Event.BranchUpdated, { branch: next }) - } + const next = await get() + if (next === value.current) return + log.info("branch changed", { from: value.current, to: next }) + value.current = next + Bus.publish(Event.BranchUpdated, { branch: next }) }), ), ), @@ -95,11 +187,34 @@ export namespace Vcs { branch: Effect.fn("Vcs.branch")(function* () { return yield* InstanceState.use(state, (x) => x.current) }), + defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { + return yield* InstanceState.use(state, (x) => x.root?.name) + }), + diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { + const value = yield* InstanceState.get(state) + if (Instance.project.vcs !== "git") return [] + if (mode === "git") { + return yield* track( + fs, + git, + Instance.directory, + (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined, + ) + } + + if (!value.root) return [] + if (value.current && value.current === value.root.name) return [] + const ref = yield* git.mergeBase(Instance.directory, value.root.ref) + if (!ref) return [] + return yield* compare(fs, git, Instance.directory, ref) + }), }) }), ) - const runPromise = makeRunPromise(Service, layer) + export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(AppFileSystem.defaultLayer)) + + const runPromise = makeRunPromise(Service, defaultLayer) export function init() { return runPromise((svc) => svc.init()) @@ -108,4 +223,12 @@ export namespace Vcs { export function branch() { return runPromise((svc) => svc.branch()) } + + export function defaultBranch() { + return runPromise((svc) => svc.defaultBranch()) + } + + export function diff(mode: Mode) { + return runPromise((svc) => svc.diff(mode)) + } } diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts new file mode 100644 index 0000000000..a897a38e68 --- /dev/null +++ b/packages/opencode/test/git/git.test.ts @@ -0,0 +1,128 @@ +import { $ } from "bun" +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ManagedRuntime } from "effect" +import { Git } from "../../src/git" +import { tmpdir } from "../fixture/fixture" + +const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" + +async function withGit(body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { + const rt = ManagedRuntime.make(Git.defaultLayer) + try { + return await body(rt) + } finally { + await rt.dispose() + } +} + +describe("Git", () => { + test("branch() returns current branch name", async () => { + await using tmp = await tmpdir({ git: true }) + + await withGit(async (rt) => { + const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + expect(branch).toBeDefined() + expect(typeof branch).toBe("string") + }) + }) + + test("branch() returns undefined for non-git directories", async () => { + await using tmp = await tmpdir() + + await withGit(async (rt) => { + const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + expect(branch).toBeUndefined() + }) + }) + + test("branch() returns undefined for detached HEAD", async () => { + await using tmp = await tmpdir({ git: true }) + const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim() + await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet() + + await withGit(async (rt) => { + const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + expect(branch).toBeUndefined() + }) + }) + + test("defaultBranch() uses init.defaultBranch when available", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M trunk`.cwd(tmp.path).quiet() + await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() + + await withGit(async (rt) => { + const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path))) + expect(branch?.name).toBe("trunk") + expect(branch?.ref).toBe("trunk") + }) + }) + + test("status() handles special filenames", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") + + await withGit(async (rt) => { + const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path))) + expect(status).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + status: "added", + }), + ]), + ) + }) + }) + + test("diff(), stats(), and mergeBase() parse tracked changes", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M main`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await $`git checkout -b feature/test`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + + await withGit(async (rt) => { + const [base, diff, stats] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))), + rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))), + rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))), + ]) + + expect(base).toBeTruthy() + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + status: "modified", + }), + ]), + ) + expect(stats).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + additions: 1, + deletions: 1, + }), + ]), + ) + }) + }) + + test("show() returns empty text for binary blobs", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet() + + await withGit(async (rt) => { + const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat"))) + expect(text).toBe("") + }) + }) +}) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 11463b7950..f55989caff 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((s) => s.init())) await rt.runPromise(Vcs.Service.use((s) => s.init())) @@ -34,7 +34,15 @@ function withVcs( ) } +function withVcsOnly( + directory: string, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, +) { + return withServices(directory, Vcs.defaultLayer, body) +} + type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } +const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ function nextBranchUpdate(directory: string, timeout = 10_000) { @@ -123,3 +131,105 @@ describeVcs("Vcs", () => { }) }) }) + +describe("Vcs diff", () => { + afterEach(async () => { + await Instance.disposeAll() + }) + + test("defaultBranch() falls back to main", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M main`.cwd(tmp.path).quiet() + + await withVcsOnly(tmp.path, async (rt) => { + const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) + expect(branch).toBe("main") + }) + }) + + test("defaultBranch() uses init.defaultBranch when available", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M trunk`.cwd(tmp.path).quiet() + await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() + + await withVcsOnly(tmp.path, async (rt) => { + const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) + expect(branch).toBe("trunk") + }) + }) + + test("detects current branch from the active worktree", async () => { + await using tmp = await tmpdir({ git: true }) + await using wt = await tmpdir() + await $`git branch -M main`.cwd(tmp.path).quiet() + const dir = path.join(wt.path, "feature") + await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() + + await withVcsOnly(dir, async (rt) => { + const [branch, base] = await Promise.all([ + rt.runPromise(Vcs.Service.use((s) => s.branch())), + rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())), + ]) + expect(branch).toBe("feature/test") + expect(base).toBe("main") + }) + }) + + test("diff('git') returns uncommitted changes", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") + + await withVcsOnly(tmp.path, async (rt) => { + const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "file.txt", + status: "modified", + }), + ]), + ) + }) + }) + + test("diff('git') handles special filenames", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") + + await withVcsOnly(tmp.path, async (rt) => { + const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + status: "added", + }), + ]), + ) + }) + }) + + test("diff('branch') returns changes against default branch", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M main`.cwd(tmp.path).quiet() + await $`git checkout -b feature/test`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() + + await withVcsOnly(tmp.path, async (rt) => { + const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("branch"))) + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "branch.txt", + status: "added", + }), + ]), + ) + }) + }) +})