Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/opencode/src/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -86,6 +89,11 @@ export interface Interface {
readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly diffUntracked: (
cwd: string,
files: string[],
options?: PatchOptions,
) => Effect.Effect<{ patch: Patch; stats: Stat[] }>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
}
Expand Down Expand Up @@ -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/)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -337,6 +415,7 @@ const layer = Layer.effect(
patch,
patchAll,
patchUntracked,
diffUntracked,
statUntracked,
applyPatch,
})
Expand Down
56 changes: 50 additions & 6 deletions packages/opencode/src/project/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type DiffOptions = {
readonly context?: number
}

type PatchBatch = { patches: Map<string, string>; capped: boolean; includesUntracked: boolean }

const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))

const nums = (list: Git.Stat[]) =>
Expand All @@ -28,7 +30,11 @@ const merge = (...lists: Git.Item[][]) => {
return [...out.values()]
}

const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const emptyBatch = (includesUntracked = false): PatchBatch => ({
patches: new Map<string, string>(),
capped: false,
includesUntracked,
})

const parseQuotedPath = (value: string) => {
let out = ""
Expand Down Expand Up @@ -101,7 +107,7 @@ const batchPatches = Effect.fnUntraced(function* (
list: Git.Item[],
options?: DiffOptions,
) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
if (list.length === 0) return emptyBatch()

const result = yield* git.patchAll(cwd, ref, {
context: options?.context ?? PATCH_CONTEXT_LINES,
Expand All @@ -116,6 +122,40 @@ const batchPatches = Effect.fnUntraced(function* (
return acc
}, new Map<string, string>()),
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<string, string>()),
capped: result.patch.truncated,
includesUntracked: true,
} satisfies PatchBatch,
stats: result.stats,
}
})

Expand Down Expand Up @@ -151,15 +191,15 @@ const patchForItem = Effect.fnUntraced(function* (
cwd: string,
ref: string | undefined,
item: Git.Item,
batch: { patches: Map<string, string>; capped: boolean },
batch: PatchBatch,
capped: boolean,
options?: DiffOptions,
) {
if (capped) return emptyPatch(item.file)

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)
})

Expand All @@ -169,7 +209,7 @@ const files = Effect.fnUntraced(function* (
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
batch: PatchBatch,
options?: DiffOptions,
) {
const next: FileDiff[] = []
Expand Down Expand Up @@ -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)
})

Expand Down
136 changes: 136 additions & 0 deletions packages/opencode/test/project/vcs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`)
Expand Down Expand Up @@ -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",
() =>
Expand All @@ -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",
() =>
Expand Down
Loading