resolve merge conflicts

This commit is contained in:
Catriel Müller
2026-03-17 11:37:03 -03:00
175 changed files with 8673 additions and 3441 deletions
@@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
}
async function check(map: (dir: string) => string) {
if (process.platform !== "win32") return
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
Config.global.reset()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
snapshot: false,
})
await Instance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await Config.get()
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe("global")
},
})
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
Config.global.reset()
}
}
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -56,6 +84,23 @@ test("loads JSON config file", async () => {
})
})
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/${drive}${rest}`
})
})
test("loads project config from Cygwin paths on Windows", async () => {
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/cygdrive/${drive}${rest}`
})
})
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
+3 -1
View File
@@ -50,7 +50,7 @@ const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(path.join(cacheDir, "version"), "14")
// Clear provider env vars to ensure clean test state
// Clear provider and server auth env vars to ensure clean test state
delete process.env["ANTHROPIC_API_KEY"]
delete process.env["OPENAI_API_KEY"]
delete process.env["GOOGLE_API_KEY"]
@@ -70,6 +70,8 @@ delete process.env["DEEPSEEK_API_KEY"]
delete process.env["FIREWORKS_API_KEY"]
delete process.env["CEREBRAS_API_KEY"]
delete process.env["SAMBANOVA_API_KEY"]
delete process.env["OPENCODE_SERVER_PASSWORD"]
delete process.env["OPENCODE_SERVER_USERNAME"]
// Now safe to import from src/
const { Log } = await import("../src/util/log")
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
const wait = async (fn: () => boolean, ms = 2000) => {
const end = Date.now() + ms
while (Date.now() < end) {
if (fn()) return
await sleep(25)
}
throw new Error("timeout waiting for pty events")
}
const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => {
return log.filter((evt) => evt.id === id).map((evt) => evt.type)
}
describe("pty", () => {
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id = ""
try {
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
id = info.id
await wait(() => pick(log, id).includes("exited"))
await Pty.remove(id)
await wait(() => pick(log, id).length >= 3)
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
test("publishes created, exited, deleted in order for /bin/sh + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id = ""
try {
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
id = info.id
await sleep(100)
await Pty.remove(id)
await wait(() => pick(log, id).length >= 3)
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
})
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import path from "path"
import { GlobalBus } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Filesystem } from "../../src/util/filesystem"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
afterEach(async () => {
await resetDatabase()
})
describe("project.initGit endpoint", () => {
test("initializes git and reloads immediately", async () => {
await using tmp = await tmpdir()
const app = Server.App()
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
}
const reload = Instance.reload
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
GlobalBus.on("event", fn)
try {
const init = await app.request("/project/git/init", {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
},
})
const body = await init.json()
expect(init.status).toBe(200)
expect(body).toMatchObject({
id: "global",
vcs: "git",
worktree: tmp.path,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap)
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
true,
)
expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false)
const current = await app.request("/project/current", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({
id: "global",
vcs: "git",
worktree: tmp.path,
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
} finally {
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
})
test("does not reload when the project is already git", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.App()
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
}
const reload = Instance.reload
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
GlobalBus.on("event", fn)
try {
const init = await app.request("/project/git/init", {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(init.status).toBe(200)
expect(await init.json()).toMatchObject({
vcs: "git",
worktree: tmp.path,
})
expect(
seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length,
).toBe(0)
expect(reloadSpy).toHaveBeenCalledTimes(0)
const current = await app.request("/project/current", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({
vcs: "git",
worktree: tmp.path,
})
} finally {
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
})
})
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Database } from "../../src/storage/db"
describe("Database.file", () => {
test("uses the shared database for latest", () => {
expect(Database.file("latest")).toBe("opencode.db")
})
test("sanitizes preview channels for filenames", () => {
expect(Database.file("fix/windows-modified-files-tracking")).toBe("opencode-fix-windows-modified-files-tracking.db")
})
})
@@ -84,6 +84,7 @@ function createTestDb() {
.map((entry) => ({
sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
timestamp: Number(entry.name.split("_")[0]),
name: entry.name,
}))
.sort((a, b) => a.timestamp - b.timestamp)
migrate(drizzle({ client: sqlite }), migrations)
+183
View File
@@ -451,6 +451,189 @@ describe("tool.edit", () => {
})
})
describe("line endings", () => {
const old = "alpha\nbeta\ngamma"
const next = "alpha\nbeta-updated\ngamma"
const alt = "alpha\nbeta\nomega"
const normalize = (text: string, ending: "\n" | "\r\n") => {
const normalized = text.replaceAll("\r\n", "\n")
if (ending === "\n") return normalized
return normalized.replaceAll("\n", "\r\n")
}
const count = (content: string) => {
const crlf = content.match(/\r\n/g)?.length ?? 0
const lf = content.match(/\n/g)?.length ?? 0
return {
crlf,
lf: lf - crlf,
}
}
const expectLf = (content: string) => {
const counts = count(content)
expect(counts.crlf).toBe(0)
expect(counts.lf).toBeGreaterThan(0)
}
const expectCrlf = (content: string) => {
const counts = count(content)
expect(counts.lf).toBe(0)
expect(counts.crlf).toBeGreaterThan(0)
}
type Input = {
content: string
oldString: string
newString: string
replaceAll?: boolean
}
const apply = async (input: Input) => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), input.content)
},
})
return await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const filePath = path.join(tmp.path, "test.txt")
FileTime.read(ctx.sessionID, filePath)
await edit.execute(
{
filePath,
oldString: input.oldString,
newString: input.newString,
replaceAll: input.replaceAll,
},
ctx,
)
return await Bun.file(filePath).text()
},
})
}
test("preserves LF with LF multi-line strings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with CRLF multi-line strings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when old/new use CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when old/new use LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when newString uses CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when newString uses LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: "alpha\nbeta\r\ngamma",
newString: "alpha\r\nbeta\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: "alpha\r\nbeta\ngamma",
newString: "alpha\nbeta\r\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\r\n"))
expectCrlf(output)
})
test("replaceAll preserves LF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\n"),
newString: normalize(blockNew, "\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
expectLf(output)
})
test("replaceAll preserves CRLF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\r\n"),
newString: normalize(blockNew, "\r\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
expectCrlf(output)
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()
@@ -440,4 +440,67 @@ describe("filesystem", () => {
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
describe("resolve()", () => {
test("resolves slash-prefixed drive paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const forward = tmp.path.replaceAll("\\", "/")
expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves slash-prefixed drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toUpperCase()
expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
})
test("resolves Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
// Git Bash and MSYS2 both use /<drive> paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves Cygwin paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Cygwin drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves WSL mount paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves WSL mount roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
})
})
+18
View File
@@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv {
}
}
function envPath(Path: string): NodeJS.ProcessEnv {
return {
Path,
PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
@@ -79,4 +86,15 @@ describe("util.which", () => {
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
test("uses Windows Path casing fallback", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "mixed")
same(which("mixed", envPath(bin)), file)
})
})