diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 968e394a959f..3c59fc190eba 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -2,6 +2,9 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" import { Effect, Layer, Context, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" +import fs from "fs/promises" +import os from "os" +import path from "path" const cfg = [ "--no-optional-locks", @@ -86,6 +89,11 @@ export interface Interface { readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly diffUntracked: ( + cwd: string, + files: string[], + options?: PatchOptions, + ) => Effect.Effect<{ patch: Patch; stats: Stat[] }> readonly statUntracked: (cwd: string, file: string) => Effect.Effect readonly applyPatch: (cwd: string, patch: string) => Effect.Effect } @@ -135,6 +143,8 @@ const layer = Layer.effect( return (yield* run(args, opts)).text() }) + const pathspecs = (files: string[]) => stdin(files.map((file) => `:(top,literal)${file}`).join("\0") + "\0") + const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { return (yield* text(args, opts)) .split(/\r?\n/) @@ -298,6 +308,74 @@ const layer = Layer.effect( return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch }) + const diffUntracked = Effect.fn("Git.diffUntracked")(function* ( + cwd: string, + files: string[], + options?: PatchOptions, + ) { + if (files.length === 0) { + return { patch: { text: "", truncated: false }, stats: [] } satisfies { patch: Patch; stats: Stat[] } + } + + return yield* Effect.acquireUseRelease( + Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-git-index-"))), + (dir) => + Effect.gen(function* () { + const env = { GIT_INDEX_FILE: path.join(dir, "index") } + + // Fail closed: if intent-to-add fails (index lock, unreadable pathspec) the temp + // index is unreliable, so return an empty batch and let the caller fall back to + // per-file native patches rather than silently reporting no untracked changes. + const added = yield* run(["add", "--intent-to-add", "--pathspec-from-file=-", "--pathspec-file-nul"], { + cwd, + env, + stdin: pathspecs(files), + }) + if (added.exitCode !== 0) { + return { patch: { text: "", truncated: false }, stats: [] } satisfies { patch: Patch; stats: Stat[] } + } + + const [patch, stats] = yield* Effect.all( + [ + run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, "--", "."], + { + cwd, + env, + maxOutputBytes: options?.maxOutputBytes, + }, + ), + text(["diff", "--numstat", "-z", "--", "."], { cwd, env }), + ], + { concurrency: 2 }, + ) + + return { + patch: { text: patch.text(), truncated: patch.truncated }, + stats: nuls(stats).flatMap((item) => { + const a = item.indexOf("\t") + const b = item.indexOf("\t", a + 1) + if (a === -1 || b === -1) return [] + const file = item.slice(b + 1) + if (!file) return [] + const additions = item.slice(0, a) + const deletions = item.slice(a + 1, b) + const added = additions === "-" ? 0 : Number.parseInt(additions || "0", 10) + const deleted = deletions === "-" ? 0 : Number.parseInt(deletions || "0", 10) + return [ + { + file, + additions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + } satisfies Stat, + ] + }), + } satisfies { patch: Patch; stats: Stat[] } + }), + (dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })), + ) + }) + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { cwd, @@ -337,6 +415,7 @@ const layer = Layer.effect( patch, patchAll, patchUntracked, + diffUntracked, statUntracked, applyPatch, }) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index eca56c0501a1..9beabf3af744 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -15,6 +15,8 @@ type DiffOptions = { readonly context?: number } +type PatchBatch = { patches: Map; capped: boolean; includesUntracked: boolean } + const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => @@ -28,7 +30,11 @@ const merge = (...lists: Git.Item[][]) => { return [...out.values()] } -const emptyBatch = () => ({ patches: new Map(), capped: false }) +const emptyBatch = (includesUntracked = false): PatchBatch => ({ + patches: new Map(), + capped: false, + includesUntracked, +}) const parseQuotedPath = (value: string) => { let out = "" @@ -101,7 +107,7 @@ const batchPatches = Effect.fnUntraced(function* ( list: Git.Item[], options?: DiffOptions, ) { - if (list.length === 0) return { patches: new Map(), capped: false } + if (list.length === 0) return emptyBatch() const result = yield* git.patchAll(cwd, ref, { context: options?.context ?? PATCH_CONTEXT_LINES, @@ -116,6 +122,40 @@ const batchPatches = Effect.fnUntraced(function* ( return acc }, new Map()), capped: result.truncated, + includesUntracked: false, + } +}) + +const batchUntracked = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + list: Git.Item[], + options?: DiffOptions, +) { + const added = list.filter((item) => item.status === "added") + if (added.length === 0) return { batch: emptyBatch(true), stats: [] } + + const result = yield* git.diffUntracked( + cwd, + added.map((item) => item.file), + { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }, + ) + + return { + batch: { + patches: splitGitPatch(result.patch).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? added[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.patch.truncated, + includesUntracked: true, + } satisfies PatchBatch, + stats: result.stats, } }) @@ -151,7 +191,7 @@ const patchForItem = Effect.fnUntraced(function* ( cwd: string, ref: string | undefined, item: Git.Item, - batch: { patches: Map; capped: boolean }, + batch: PatchBatch, capped: boolean, options?: DiffOptions, ) { @@ -159,7 +199,7 @@ const patchForItem = Effect.fnUntraced(function* ( const batched = batch.patches.get(item.file) if (batched !== undefined) return batched - if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + if (batch.capped && (item.code !== "??" || batch.includesUntracked)) return emptyPatch(item.file) return yield* nativePatch(git, cwd, ref, item, options) }) @@ -169,7 +209,7 @@ const files = Effect.fnUntraced(function* ( ref: string | undefined, list: Git.Item[], map: Map, - batch: { patches: Map; capped: boolean }, + batch: PatchBatch, options?: DiffOptions, ) { const next: FileDiff[] = [] @@ -228,7 +268,11 @@ const track = Effect.fnUntraced(function* ( ref: string | undefined, options?: DiffOptions, ) { - if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch(), options) + if (!ref) { + const list = yield* git.status(cwd) + const { batch, stats } = yield* batchUntracked(git, cwd, list, options) + return yield* files(git, cwd, ref, list, nums(stats), batch, options) + } return yield* diffAgainstRef(git, cwd, ref, options) }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index c13b108bc6a9..75494cb8e30d 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -31,6 +31,73 @@ const layer = LayerNode.compile( const it = testEffect(layer) const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) +let fakeGitCalls: string[] = [] +const unexpectedGitCall = (name: string) => Effect.die(new Error(`unexpected Git.${name} call`)) +const fakeGit = Layer.succeed( + Git.Service, + Git.Service.of({ + run: () => unexpectedGitCall("run"), + branch: () => Effect.succeed("main"), + prefix: () => Effect.succeed(""), + defaultBranch: () => Effect.succeed({ name: "main", ref: "main" }), + hasHead: () => + Effect.sync(() => { + fakeGitCalls.push("hasHead") + return false + }), + mergeBase: () => unexpectedGitCall("mergeBase"), + show: () => unexpectedGitCall("show"), + status: () => + Effect.sync(() => { + fakeGitCalls.push("status") + return [ + { file: "a.txt", code: "??", status: "added" }, + { file: "b.txt", code: "??", status: "added" }, + ] satisfies Git.Item[] + }), + diff: () => unexpectedGitCall("diff"), + stats: () => unexpectedGitCall("stats"), + patch: () => unexpectedGitCall("patch"), + patchAll: () => unexpectedGitCall("patchAll"), + patchUntracked: () => unexpectedGitCall("patchUntracked"), + diffUntracked: (_cwd, files) => + Effect.sync(() => { + fakeGitCalls.push(`diffUntracked:${files.join(",")}`) + return { + patch: { + text: + "diff --git a/a.txt b/a.txt\n" + + "new file mode 100644\n" + + "index 0000000..5626abf\n" + + "--- /dev/null\n" + + "+++ b/a.txt\n" + + "@@ -0,0 +1 @@\n" + + "+alpha\n" + + "diff --git a/b.txt b/b.txt\n" + + "new file mode 100644\n" + + "index 0000000..f719efd\n" + + "--- /dev/null\n" + + "+++ b/b.txt\n" + + "@@ -0,0 +1 @@\n" + + "+beta\n", + truncated: false, + }, + stats: [ + { file: "a.txt", additions: 1, deletions: 0 }, + { file: "b.txt", additions: 1, deletions: 0 }, + ], + } + }), + statUntracked: () => unexpectedGitCall("statUntracked"), + applyPatch: () => unexpectedGitCall("applyPatch"), + }), +) +const fakeGitIt = testEffect( + LayerNode.compile(LayerNode.group([Vcs.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node]), [ + [Git.node, fakeGit], + ]), +) + const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) { const result = yield* Git.Service.use((git) => git.run(args, { cwd })) if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`) @@ -236,6 +303,55 @@ describe("Vcs diff", () => { { git: true }, ) + it.instance( + "diff('git') returns no-HEAD untracked patches without touching the index", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "a.txt"), "one\ntwo\n") + yield* write(path.join(test.directory, "b.txt"), "three\n") + + const vcs = yield* init() + const diff = yield* vcs.diff("git") + const status = yield* Git.Service.use((git) => git.status(test.directory)) + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") + + expect(a).toEqual(expect.objectContaining({ additions: 2, deletions: 0, status: "added" })) + expect(a?.patch).toContain("diff --git") + expect(a?.patch).toContain("new file mode") + expect(a?.patch).toContain("+one") + expect(b).toEqual(expect.objectContaining({ additions: 1, deletions: 0, status: "added" })) + expect(status).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "a.txt", code: "??" }), + expect.objectContaining({ file: "b.txt", code: "??" }), + ]), + ) + }), + { init: (directory) => git(directory, ["init"]) }, + ) + + fakeGitIt.instance( + "diff('git') batches no-HEAD untracked patch and stat checks", + () => + Effect.gen(function* () { + fakeGitCalls = [] + + const vcs = yield* init() + const diff = yield* vcs.diff("git") + + expect(diff).toEqual([ + expect.objectContaining({ file: "a.txt", additions: 1, deletions: 0, status: "added" }), + expect.objectContaining({ file: "b.txt", additions: 1, deletions: 0, status: "added" }), + ]) + expect(diff[0]?.patch).toContain("+alpha") + expect(diff[1]?.patch).toContain("+beta") + expect(fakeGitCalls).toEqual(["hasHead", "status", "diffUntracked:a.txt,b.txt"]) + }), + { git: true }, + ) + it.instance( "diff('git') handles special filenames", () => @@ -258,6 +374,26 @@ describe("Vcs diff", () => { { git: true }, ) + it.instance( + "diff('git') handles special filenames with no HEAD via untracked batching", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, weird), "hello\n") + + // No commit => no HEAD => routes through diffUntracked, which passes the + // filename as a NUL-delimited `:(top,literal)` pathspec. This exercises the + // escaping path that the has-HEAD "special filenames" test above does not. + const vcs = yield* init() + const diff = yield* vcs.diff("git") + const entry = diff.find((item) => item.file === weird) + + expect(entry).toEqual(expect.objectContaining({ file: weird, additions: 1, deletions: 0, status: "added" })) + expect(entry?.patch).toContain("+hello") + }), + { init: (directory) => git(directory, ["init"]) }, + ) + it.instance( "diff('git') keeps batched patches aligned for type changes", () =>