mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49cf70c363 | ||
|
|
5bc9a09cca | ||
|
|
1569399bb1 | ||
|
|
ec1cc9f72a | ||
|
|
b33176fb5d |
+222
-90
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -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()]]))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user