Compare commits

...
5 changed files with 746 additions and 100 deletions
+222 -90
View File
@@ -1,7 +1,7 @@
export * as Git from "./git.js"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath, RelativePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -149,6 +150,7 @@ export interface Interface {
from: TreeID
to: TreeID
context?: number
/** Exact files or directory prefixes; directory selectors return combined diffs. */
paths?: readonly RelativePath[]
}) => Effect.Effect<readonly File.Diff[], OperationError>
readonly restore: (input: {
@@ -296,14 +298,6 @@ const layer = Layer.effect(
yield* operation("reset", repository.worktree, ["reset", "--hard", revision])
})
const repositoryArgs = (repository: Repository, args: string[]) => [
"--git-dir",
repository.gitDirectory,
"--work-tree",
repository.worktree,
...args,
]
const repositoryOperation = Effect.fnUntraced(function* (
operationName: OperationError["operation"],
repository: Repository,
@@ -519,95 +513,169 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
}),
)
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
const text = (yield* repositoryOperation("restore", repository, [
"ls-tree",
"-z",
tree,
const diffs = new Map<RelativePath, File.Diff>()
const directories = new Map<RelativePath, RelativePath[]>()
const binaries = new Map<string, string>()
// Stable headers keep user diff settings from mixing neighboring files' patch chunks.
const diffArgs = (options: string[], paths: readonly RelativePath[]) => [
"--literal-pathspecs",
"diff",
"--no-ext-diff",
"--no-color",
"--no-renames",
"--src-prefix=a/",
"--dst-prefix=b/",
"--submodule=short",
...options,
input.from,
input.to,
"--",
file,
])).text.replace(/\0$/, "")
if (!text) return false
if (!/^\d+\s+\w+\s+[0-9a-f]+\t/.test(text))
return yield* new OperationError({
operation: "restore",
directory: repository.worktree,
message: `Invalid tree entry for ${file}`,
})
return true
...paths,
]
const read = (options: string[], paths: readonly RelativePath[]) =>
repositoryOperation("diff", input.repository, diffArgs(options, paths))
for (const paths of input.paths === undefined
? [[]]
: pathBatches(input.paths.map((file) => repositoryPath(input.repository, file)))) {
const names = (yield* read(["--name-status", "-z"], paths)).text.split("\0")
const statuses = new Map(
names.flatMap((status, index) => (index % 2 === 0 && status ? [[names[index + 1], status] as const] : [])),
)
if (statuses.size === 0) continue
const parents = new Set<string>()
for (const file of statuses.keys()) {
for (let index = file.lastIndexOf("/"); index !== -1; index = file.lastIndexOf("/", index - 1)) {
parents.add(file.slice(0, index))
}
}
for (const selected of paths) {
if (selected !== "." && !parents.has(selected)) continue
const files = Array.from(statuses.keys())
.filter((file) => selected === "." || file === selected || file.startsWith(selected + "/"))
.map((file) => RelativePath.make(file))
if (files.length > 0) directories.set(selected, files)
}
const result = yield* Effect.all(
{
stats: read(["--numstat", "-z"], paths),
patches: readPatches(proc, input.repository, diffArgs([`--unified=${input.context ?? 3}`], paths)),
},
{ concurrency: 2 },
)
for (const entry of result.stats.text.split("\0").filter(Boolean)) {
const first = entry.indexOf("\t")
const second = entry.indexOf("\t", first + 1)
const file = RelativePath.make(entry.slice(second + 1))
const additions = entry.slice(0, first)
const deletions = entry.slice(first + 1, second)
const binary = additions === "-" || deletions === "-"
const status = statuses.get(file)
const patch = result.patches.get(file)
if (!binary && patch === undefined && (Number(additions) > 0 || Number(deletions) > 0))
return yield* new OperationError({
operation: "diff",
directory: input.repository.worktree,
message: `Could not parse Git patch for ${file}`,
})
if (binary) binaries.set(file, patch ?? "")
diffs.set(file, {
file,
status: status === "A" ? "added" : status === "D" ? "deleted" : "modified",
additions: binary ? 0 : Number(additions),
deletions: binary ? 0 : Number(deletions),
patch: binary ? "" : (patch ?? ""),
})
}
}
return input.paths === undefined
? Array.from(diffs.values())
: input.paths.map((file) => {
const normalized = repositoryPath(input.repository, file)
const diff = diffs.get(normalized)
if (diff && !directories.has(normalized)) return { ...diff, file }
const children = (directories.get(normalized) ?? []).flatMap((path) => {
const child = diffs.get(path)
return child ? [child] : []
})
return {
file,
status:
children.length > 0 && children.every((child) => child.status === children[0].status)
? children[0].status
: ("modified" as const),
additions: children.reduce((total, child) => total + child.additions, 0),
deletions: children.reduce((total, child) => total + child.deletions, 0),
patch: children.map((child) => binaries.get(child.file) ?? child.patch).join(""),
}
})
})
const restore = Effect.fn("Git.tree.restore")(
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
locked(
input.repository,
Effect.forEach(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (yield* hasEntry(input.repository, tree, file)) {
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
Effect.gen(function* () {
// Only group consecutive snapshots: directory restores can overlap paths from another snapshot.
const groups: { tree: TreeID; files: RelativePath[] }[] = []
for (const [inputPath, tree] of input.files) {
const file = repositoryPath(input.repository, inputPath)
const previous = groups.at(-1)
if (previous?.tree === tree) {
previous.files.push(file)
continue
}
groups.push({ tree, files: [file] })
}
for (const group of groups) {
// Checkout uses stdin, so its batch can span multiple argv-limited lookups.
const pending: RelativePath[] = []
const flush = () =>
pending.length === 0
? Effect.void
: repositoryOperation(
"restore",
input.repository,
["--literal-pathspecs", "checkout", group.tree, "--pathspec-from-file=-", "--pathspec-file-nul"],
{ stdin: pending.splice(0).join("\0") + "\0" },
)
for (const paths of pathBatches(group.files)) {
const entries = new Set(
(yield* repositoryOperation("restore", input.repository, [
"--literal-pathspecs",
"ls-tree",
"--name-only",
"-t",
"-z",
group.tree,
"--",
...paths,
])).text
.split("\0")
.filter(Boolean),
)
}),
{ discard: true },
),
for (const file of paths) {
if (entries.has(file) || (file === "." && entries.size > 0)) {
pending.push(file)
continue
}
// Keep deletions in their original position relative to the surrounding checkouts.
yield* flush()
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
}
}
yield* flush()
}
}),
),
)
@@ -702,6 +770,70 @@ const layer = Layer.effect(
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
function readPatches(proc: AppProcess.Interface, repository: Repository, args: string[]) {
return Effect.gen(function* () {
const collector = VcsPatch.collectGitPatch()
const handle = yield* proc.spawn(
ChildProcess.make("git", repositoryArgs(repository, args), {
cwd: repository.worktree,
extendEnv: true,
stdin: "ignore",
}),
)
const result = yield* Effect.all(
{
stdout: handle.stdout.pipe(
Stream.decodeText,
Stream.runForEach((chunk) => Effect.sync(() => collector.write(chunk))),
),
stderr: AppProcess.collectStream(handle.stderr, undefined),
code: handle.exitCode,
},
{ concurrency: "unbounded" },
)
if (result.code !== 0)
return yield* new OperationError({
operation: "diff",
directory: repository.worktree,
message: result.stderr.buffer.toString("utf8").trim() || "Git diff failed",
})
return collector.end()
}).pipe(
Effect.scoped,
Effect.mapError((cause) =>
cause instanceof OperationError
? cause
: new OperationError({ operation: "diff", directory: repository.worktree, message: cause.message, cause }),
),
)
}
function repositoryArgs(repository: Repository, args: string[]) {
return ["--git-dir", repository.gitDirectory, "--work-tree", repository.worktree, ...args]
}
function repositoryPath(repository: Repository, file: RelativePath) {
return RelativePath.make(
path.relative(repository.worktree, path.resolve(repository.worktree, file)).split(path.sep).join("/") || ".",
)
}
// diff and ls-tree have no pathspec-from-file option. Bound argv size, including quoting overhead on Windows.
function pathBatches(paths: readonly RelativePath[]) {
const batches: RelativePath[][] = []
let bytes = 0
for (const file of paths) {
const size = Buffer.byteLength(file) + 3
if (batches.length === 0 || bytes + size > 16_384) {
batches.push([])
bytes = 0
}
batches[batches.length - 1].push(file)
bytes += size
}
return batches
}
interface Result {
readonly exitCode: number
readonly text: string
+1
View File
@@ -28,6 +28,7 @@ export interface CompareInput {
export interface DiffInput extends CompareInput {
readonly context?: number
/** Project-relative files or directories. Each directory selector produces a combined diff. */
readonly paths?: readonly RelativePath[]
}
+62 -10
View File
@@ -31,6 +31,16 @@ export const countPatch = (patch: string) => {
return { additions, deletions }
}
const pathEscapes = new Map([
["a", "\x07"],
["b", "\b"],
["f", "\f"],
["v", "\v"],
["t", "\t"],
["n", "\n"],
["r", "\r"],
])
const parseQuotedPath = (value: string) => {
let out = ""
for (let idx = 1; idx < value.length; idx++) {
@@ -42,12 +52,16 @@ const parseQuotedPath = (value: string) => {
}
const next = value[++idx]
if (next === "t") out += "\t"
else if (next === "n") out += "\n"
else if (next === "r") out += "\r"
else if (next === '"' || next === "\\") out += next
else out += next ?? ""
// Git encodes non-ASCII filenames as octal UTF-8 bytes, not JavaScript string escapes.
const octal = /^[0-7]{1,3}(?:\\[0-7]{1,3})*/.exec(value.slice(idx))?.[0]
if (octal) {
out += Buffer.from(octal.split("\\").map((byte) => Number.parseInt(byte, 8))).toString("utf8")
idx += octal.length - 1
continue
}
out += pathEscapes.get(next) ?? next ?? ""
}
return undefined
}
const parsePathToken = (value: string) => {
@@ -56,7 +70,7 @@ const parsePathToken = (value: string) => {
}
const fileFromDiffPath = (value: string | undefined) => {
if (!value || value === "/dev/null") return
if (!value || value === "/dev/null") return undefined
const file = parsePathToken(value)
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
@@ -66,13 +80,15 @@ const fileFromGitHeader = (header: string) => {
if (header.startsWith('"')) {
const first = parseQuotedPath(header)
const second = first ? header.slice(first.end).trimStart() : undefined
if (!second) return
if (!second.startsWith('"')) return fileFromDiffPath(second)
return fileFromDiffPath(parseQuotedPath(second)?.value)
if (!second) return undefined
return fileFromDiffPath(second)
}
// Mode-only changes have no ---/+++ lines. A filename may itself contain " b/".
const file = header.slice(2, Math.floor(header.length / 2))
if (header === `a/${file} b/${file}`) return file
const separator = header.indexOf(" b/")
if (separator === -1) return
if (separator === -1) return undefined
return fileFromDiffPath(header.slice(separator + 1))
}
@@ -104,3 +120,39 @@ export const chunksByFile = (patch: Patch, fallback: (index: number) => string |
acc.set(file, (acc.get(file) ?? "") + chunk)
return acc
}, new Map<string, string>())
/** Assemble streamed Git output one file at a time, preserving every character. */
export function collectGitPatch() {
const files = new Map<string, string>()
const fragments: string[] = []
const marker = "\ndiff --git "
let tail = ""
const flush = () => {
if (fragments.length === 0) return
const text = fragments.join("")
fragments.length = 0
const file = fileFromPatchChunk(text)
if (file) files.set(file, (files.get(file) ?? "") + text)
}
return {
write(chunk: string) {
const text = tail + chunk
let start = 0
for (let index = text.indexOf(marker); index !== -1; index = text.indexOf(marker, start)) {
fragments.push(text.slice(start, index + 1))
flush()
start = index + 1
}
// Keep only enough lookbehind to recognize a header split between reads.
const end = Math.max(start, text.length - marker.length + 1)
if (end > start) fragments.push(text.slice(start, end))
tail = text.slice(end)
},
end() {
if (tail) fragments.push(tail)
tail = ""
flush()
return files
},
}
}
+413
View File
@@ -0,0 +1,413 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { initRepo } from "./fixture/git"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Git.node))
const write = (directory: string, files: Record<string, string | Uint8Array>) =>
Effect.promise(() =>
Promise.all(
Object.entries(files).map(async ([file, content]) => {
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true })
await Bun.write(path.join(directory, file), content)
}),
),
)
const fixture = Effect.fnUntraced(function* (files: Record<string, string | Uint8Array>) {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await initRepo(directory)
})
yield* write(directory, files)
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(directory))
if (!source) throw new Error("Repository not found")
const repository = yield* git.repo.create({
worktree: source.worktree,
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot")),
seed: source,
})
const capture = () => git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
return { git, repository, directory, capture, before: yield* capture() }
})
describe("Git tree batches", () => {
it.live(
"preserves per-file patches, requested ordering, binary stats, and empty changes",
() =>
Effect.gen(function* () {
const f = yield* fixture({
"changed.txt": "first\nbefore\nlast\n",
"deleted.txt": "deleted\n",
"unchanged.txt": "unchanged\n",
"binary.bin": new Uint8Array([0, 1, 2]),
"café 🦊.txt": "unicode before\n",
"nested/space name.txt": "space before\n",
})
yield* write(f.directory, {
"changed.txt": "first\nafter\nlast\n",
"added.txt": "added\n",
"empty.txt": "",
"binary.bin": new Uint8Array([0, 3, 4]),
"café 🦊.txt": "unicode after\n",
"nested/space name.txt": "space after\n",
})
yield* Effect.promise(() => fs.unlink(path.join(f.directory, "deleted.txt")))
const after = yield* f.capture()
const paths = [
"nested/space name.txt",
"café 🦊.txt",
"binary.bin",
"empty.txt",
"added.txt",
"deleted.txt",
"changed.txt",
"unchanged.txt",
].map((file) => RelativePath.make(file))
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths, context: 1 })
expect(diffs.map((diff) => [diff.file, diff.status, diff.additions, diff.deletions])).toEqual([
[paths[0], "modified", 1, 1],
[paths[1], "modified", 1, 1],
[paths[2], "modified", 0, 0],
[paths[3], "added", 0, 0],
[paths[4], "added", 1, 0],
[paths[5], "deleted", 0, 1],
[paths[6], "modified", 1, 1],
[paths[7], "modified", 0, 0],
])
for (const diff of diffs) {
if (diff.file === "binary.bin") {
expect(diff.patch).toBe("")
continue
}
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --unified=1 --no-renames ${f.before} ${after} -- ${diff.file}`
.cwd(f.directory)
.text(),
)
expect(diff.patch).toBe(expected)
}
expect(yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths: [] })).toEqual([])
expect(yield* f.git.tree.diff({ repository: f.repository, from: after, to: after })).toEqual([])
}),
{ timeout: 30_000 },
)
;(process.platform === "win32" ? it.live.skip : it.live)(
"keeps C-quoted paths and type changes paired with the correct patch",
() =>
Effect.gen(function* () {
const names = ["tab\tname.txt", "line\nname.txt", 'quote"name.txt', "back\\slash.txt", "bell\x07.txt"]
const f = yield* fixture({
...Object.fromEntries(names.map((file) => [file, "before\n"])),
"kind.txt": "was a file\n",
"path b/nested/mode.txt": "executable\n",
"café-mode.txt": "executable\n",
tab: "before\n",
"tab\tmode.txt": "executable\n",
})
yield* write(f.directory, { ...Object.fromEntries(names.map((file) => [file, "after\n"])), tab: "after\n" })
yield* Effect.promise(async () => {
await fs.unlink(path.join(f.directory, "kind.txt"))
await fs.symlink("café-mode.txt", path.join(f.directory, "kind.txt"))
await fs.chmod(path.join(f.directory, "path b/nested/mode.txt"), 0o755)
await fs.chmod(path.join(f.directory, "café-mode.txt"), 0o755)
await fs.chmod(path.join(f.directory, "tab\tmode.txt"), 0o755)
})
const after = yield* f.capture()
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after })
expect(diffs).toHaveLength(names.length + 5)
for (const diff of diffs) {
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${f.before} ${after} -- ${diff.file}`
.cwd(f.directory)
.text(),
)
expect(diff.patch).toBe(expected)
}
yield* f.git.tree.restore({
repository: f.repository,
files: new Map(diffs.map((diff) => [RelativePath.make(diff.file), f.before])),
})
for (const file of names)
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, file)).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "kind.txt")).text())).toBe("was a file\n")
expect((yield* Effect.promise(() => fs.stat(path.join(f.directory, "café-mode.txt")))).mode & 0o111).toBe(0)
}),
{ timeout: 30_000 },
)
it.live(
"streams multi-buffer UTF-8 patches without changing CRLF content",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "large.txt": "before\r\n" })
yield* write(f.directory, { "large.txt": "after 🦊 diff --git is file content\r\n".repeat(10_000) })
const after = yield* f.capture()
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after })
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${f.before} ${after} -- large.txt`
.cwd(f.directory)
.text(),
)
expect(diffs).toHaveLength(1)
expect(diffs[0].patch).toBe(expected)
}),
{ timeout: 30_000 },
)
it.live(
"fails instead of returning partial diffs when Git cannot read a changed blob",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "changed.txt": "before\n" })
yield* write(f.directory, { "changed.txt": "after\n" })
const after = yield* f.capture()
const blob = (yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} rev-parse ${`${after}:changed.txt`}`.cwd(f.directory).text(),
)).trim()
yield* Effect.promise(async () => {
const file = path.join(f.repository.gitDirectory, "objects", blob.slice(0, 2), blob.slice(2))
await fs.unlink(file)
await fs.writeFile(file, "broken object")
})
const error = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Git.OperationError)
expect(error.operation).toBe("diff")
}),
{ timeout: 30_000 },
)
it.live(
"keeps Git display configuration from mixing neighboring patches",
() =>
Effect.gen(function* () {
const f = yield* fixture({ foo: "before\n", "a/foo": "before\n" })
yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} update-index --add --cacheinfo ${`160000,${"1".repeat(40)},z-module`}`
.cwd(f.directory)
.quiet(),
)
const before = yield* f.git.tree.write(f.repository)
yield* write(f.directory, { foo: "after\n", "a/foo": "after\n" })
yield* f.capture()
yield* Effect.promise(async () => {
await $`git --git-dir ${f.repository.gitDirectory} update-index --add --cacheinfo ${`160000,${"2".repeat(40)},z-module`}`
.cwd(f.directory)
.quiet()
await $`git --git-dir ${f.repository.gitDirectory} config diff.noprefix true`.cwd(f.directory).quiet()
await $`git --git-dir ${f.repository.gitDirectory} config diff.submodule log`.cwd(f.directory).quiet()
})
const after = yield* f.git.tree.write(f.repository)
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: before, to: after })
expect(diffs.map((diff) => diff.file)).toEqual(["a/foo", "foo", "z-module"])
for (const diff of diffs) {
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames --src-prefix=a/ --dst-prefix=b/ --submodule=short ${before} ${after} -- ${diff.file}`
.cwd(f.directory)
.text(),
)
expect(diff.patch).toBe(expected)
}
}),
{ timeout: 30_000 },
)
it.live(
"preserves restoration order across snapshots and overlapping directory paths",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "alpha.txt": "one\n", "folder/file.txt": "one\n", "untouched.txt": "one\n" })
yield* write(f.directory, { "alpha.txt": "two\n", "folder/file.txt": "two\n" })
const middle = yield* f.capture()
yield* write(f.directory, {
"alpha.txt": "three\n",
"folder/file.txt": "three\n",
"untouched.txt": "keep this edit\n",
"added.txt": "remove this\n",
})
yield* f.git.tree.restore({
repository: f.repository,
files: new Map([
[RelativePath.make("alpha.txt"), f.before],
[RelativePath.make("folder/file.txt"), middle],
[RelativePath.make("folder"), f.before],
[RelativePath.make("added.txt"), f.before],
]),
})
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "alpha.txt")).text())).toBe("one\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "folder/file.txt")).text())).toBe("one\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "untouched.txt")).text())).toBe(
"keep this edit\n",
)
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "added.txt")).exists())).toBe(false)
}),
{ timeout: 30_000 },
)
it.live(
"treats selected bracketed filenames literally",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "app/[slug]/page.tsx": "before\n", "app/s/page.tsx": "original\n" })
yield* write(f.directory, { "app/[slug]/page.tsx": "after\n", "app/s/page.tsx": "keep this edit\n" })
const after = yield* f.capture()
const file = RelativePath.make("app/[slug]/page.tsx")
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths: [file] })
expect(diffs).toHaveLength(1)
expect(diffs[0].patch).toContain("+after")
expect(diffs[0].patch).not.toContain("keep this edit")
yield* f.git.tree.restore({ repository: f.repository, files: new Map([[file, f.before]]) })
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, file)).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "app/s/page.tsx")).text())).toBe(
"keep this edit\n",
)
}),
{ timeout: 30_000 },
)
it.live(
"returns complete directory diffs and totals without including neighboring paths",
() =>
Effect.gen(function* () {
const f = yield* fixture({
"nested/a.txt": "before\n",
"nested/deeper/b.txt": "before\n",
"nested/image.bin": new Uint8Array([0, 1]),
"nested-other/outside.txt": "before\n",
})
yield* write(f.directory, {
"nested/a.txt": "after\n",
"nested/deeper/b.txt": "after\nextra\n",
"nested/image.bin": new Uint8Array([0, 2]),
"nested-other/outside.txt": "outside\n",
})
const after = yield* f.capture()
for (const selected of ["nested/", "./nested", "."]) {
const diffs = yield* f.git.tree.diff({
repository: f.repository,
from: f.before,
to: after,
paths: [RelativePath.make(selected)],
})
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${f.before} ${after} -- ${selected}`
.cwd(f.directory)
.text(),
)
expect(diffs).toHaveLength(1)
expect(diffs[0]).toEqual({
file: selected,
status: "modified",
additions: selected === "." ? 4 : 3,
deletions: selected === "." ? 3 : 2,
patch: expected,
})
}
const empty = yield* f.git.tree.diff({
repository: f.repository,
from: f.before,
to: after,
paths: [RelativePath.make("absent/")],
})
expect(empty).toEqual([{ file: "absent/", status: "modified", additions: 0, deletions: 0, patch: "" }])
}),
{ timeout: 30_000 },
)
it.live(
"includes exact and descendant changes in file-directory replacements",
() =>
Effect.gen(function* () {
const f = yield* fixture({ module: "old file\n" })
yield* Effect.promise(() => fs.unlink(path.join(f.directory, "module")))
yield* write(f.directory, { "module/index.ts": "new child\n" })
const after = yield* f.capture()
for (const [from, to] of [
[f.before, after],
[after, f.before],
]) {
const diffs = yield* f.git.tree.diff({
repository: f.repository,
from,
to,
paths: [RelativePath.make("module")],
})
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${from} ${to} -- module`
.cwd(f.directory)
.text(),
)
expect(diffs).toEqual([{ file: "module", status: "modified", additions: 1, deletions: 1, patch: expected }])
}
}),
{ timeout: 30_000 },
)
it.live(
"normalizes explicit paths before matching tree entries",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "changed.txt": "before\n", "folder/file.txt": "before\n" })
yield* write(f.directory, { "changed.txt": "after\n", "folder/file.txt": "after\n" })
const after = yield* f.capture()
const file = RelativePath.make("./changed.txt")
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths: [file] })
expect(diffs.map((diff) => [diff.file, diff.additions, diff.deletions])).toEqual([[file, 1, 1]])
yield* f.git.tree.restore({
repository: f.repository,
files: new Map([
[file, f.before],
[RelativePath.make("folder/"), f.before],
[RelativePath.make("folder/file.txt"), f.before],
]),
})
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "changed.txt")).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "folder/file.txt")).text())).toBe("before\n")
}),
{ timeout: 30_000 },
)
it.live(
"handles path lists larger than one command without touching unselected files",
() =>
Effect.gen(function* () {
const names = Array.from(
{ length: 140 },
(_, index) => `${String(index).padStart(3, "0")}-${"x".repeat(130)}.txt`,
)
const f = yield* fixture({
...Object.fromEntries(names.map((file) => [file, "before\n"])),
"unselected.txt": "original\n",
})
yield* write(f.directory, {
...Object.fromEntries(names.map((file) => [file, "after\n"])),
"unselected.txt": "keep this edit\n",
})
const after = yield* f.capture()
const paths = names.toReversed().map((file) => RelativePath.make(file))
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths })
expect(diffs.map((diff) => diff.file)).toEqual(paths)
expect(
diffs.every((diff) => diff.additions === 1 && diff.deletions === 1 && diff.patch.includes("+after")),
).toBe(true)
yield* f.git.tree.restore({ repository: f.repository, files: new Map(paths.map((file) => [file, f.before])) })
for (const file of names)
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, file)).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "unselected.txt")).text())).toBe(
"keep this edit\n",
)
}),
{ timeout: 30_000 },
)
})
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { VcsPatch } from "@opencode-ai/core/vcs/patch"
describe("streamed Git patches", () => {
const first = [
"diff --git a/first.txt b/first.txt",
"index 1111111..2222222 100644",
"--- a/first.txt",
"+++ b/first.txt",
"@@ -1 +1 @@",
"-old\r",
"+diff --git is content, not a header 🦊\r",
"\\ No newline at end of file",
"",
].join("\n")
const second = 'diff --git "a/tab\\tname.txt" "b/tab\\tname.txt"\nold mode 100644\nnew mode 100755\n'
const changedType =
"diff --git a/first.txt b/first.txt\nnew file mode 120000\nindex 0000000..3333333\n--- /dev/null\n+++ b/first.txt\n@@ -0,0 +1 @@\n+target\n"
const patch = first + second + changedType
const expected = new Map([
["first.txt", first + changedType],
["tab\tname.txt", second],
])
test("preserves patches across every two-chunk split", () => {
for (let index = 0; index <= patch.length; index++) {
const collector = VcsPatch.collectGitPatch()
collector.write(patch.slice(0, index))
collector.write(patch.slice(index))
expect(collector.end()).toEqual(expected)
}
})
test("handles small chunks, CRLF content, Unicode, and multiple chunks per file", () => {
for (const size of [1, 2, 7, 12, 64]) {
const collector = VcsPatch.collectGitPatch()
for (let offset = 0; offset < patch.length; offset += size) collector.write(patch.slice(offset, offset + size))
expect(collector.end()).toEqual(expected)
}
})
test("accepts empty output and a final patch without a newline", () => {
expect(VcsPatch.collectGitPatch().end()).toEqual(new Map())
const collector = VcsPatch.collectGitPatch()
collector.write(second.trimEnd())
expect(collector.end()).toEqual(new Map([["tab\tname.txt", second.trimEnd()]]))
})
})