Compare commits

..

1 Commits

Author SHA1 Message Date
Hona c5b22986b4 fix(core): respect repository git semantics 2026-08-12 07:38:01 +00:00
9 changed files with 157 additions and 103 deletions
+2 -4
View File
@@ -75,9 +75,7 @@ export const create = (
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
if (executed.output !== undefined) return executed.output
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) => {
@@ -157,7 +155,7 @@ function runtime(
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
output: child.outputSchema,
execute: (input) => executeTool(name, registration, input),
})
}
+56 -9
View File
@@ -22,10 +22,10 @@ const snapshotConfigFile = "opencode.gitconfig"
const snapshotConfigInclude = `[include]
path = ${snapshotConfigFile}
`
const snapshotConfig = `[core]
autocrlf = false
const snapshotConfig = (input: { autocrlf: string; symlinks: string }) => `[core]
autocrlf = ${input.autocrlf}
longpaths = true
symlinks = true
symlinks = ${input.symlinks}
fsmonitor = false
untrackedCache = true
[feature]
@@ -341,6 +341,46 @@ const layer = Layer.effect(
})
})
const sourceConfig = Effect.fnUntraced(function* (
repository: Repository | undefined,
key: string,
fallback: string,
allowInput: boolean,
) {
if (!repository) return fallback
const result = yield* execute(
repository.worktree,
proc,
)(["config", "--get", key]).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: repository.worktree,
message: `Failed to resolve ${key}`,
cause,
}),
),
)
if (result.exitCode === 0) {
const value = result.text.trim().toLowerCase()
if (allowInput && value === "input") return value
if (["true", "yes", "on", "1"].includes(value)) return "true"
if (["false", "no", "off", "0"].includes(value)) return "false"
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: `Invalid ${key} value: ${value}`,
})
}
if (result.exitCode === 1) return fallback
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: result.stderr.trim() || `Failed to resolve ${key}`,
})
})
const create = Effect.fn("Git.repo.create")(function* (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
@@ -363,14 +403,20 @@ const layer = Layer.effect(
commonDirectory: input.gitDirectory,
})
yield* repositoryOperation("create", repository, ["init"])
yield* Effect.gen(function* () {
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
const semantics = {
autocrlf: yield* sourceConfig(input.seed, "core.autocrlf", "false", true),
symlinks: yield* sourceConfig(input.seed, "core.symlinks", "true", false),
}
const reseed = yield* Effect.gen(function* () {
const owned = path.join(input.gitDirectory, snapshotConfigFile)
const desired = snapshotConfig(semantics)
const previous = yield* fs.readFileString(owned).pipe(Effect.catch(() => Effect.succeed("")))
yield* fs.writeFileString(owned, desired)
const config = path.join(input.gitDirectory, "config")
const current = yield* fs.readFileString(config)
if (current.includes(snapshotConfigInclude)) return
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
const base = current.replace(/^\s*path\s*=\s*opencode\.gitconfig\s*\r?\n?/gm, "").trimEnd()
yield* fs.writeFileString(config, `${base}\n\n${snapshotConfigInclude}`)
return previous !== desired
}).pipe(
Effect.mapError(
(cause) =>
@@ -410,6 +456,7 @@ const layer = Layer.effect(
}),
),
)
if (!reseed) return repository
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.pipe(Effect.catch(() => Effect.void))
+3 -5
View File
@@ -83,11 +83,9 @@ const layer = Layer.effect(
const gitDirectory = AbsolutePath.make(
path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)),
)
const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory })
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
const snapshotRepository = yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
}).pipe(Effect.forkIn(lifetime)),
)
-4
View File
@@ -103,14 +103,10 @@ type GitOps = ReturnType<typeof makeGit>
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
+15 -2
View File
@@ -148,21 +148,34 @@ describe("Git trees", () => {
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!source) throw new Error("Repository not found")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(root.path).quiet())
const storage = AbsolutePath.make(path.join(root.path, ".snapshot storage"))
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path first.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path second.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf true`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf false`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.symlinks true`.quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("true\n")
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.symlinks`.text()),
).toBe("false\n")
yield* Effect.promise(() => $`git config core.autocrlf input`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("input\n")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
(yield* Effect.promise(() => fs.readFile(path.join(storage, "config"), "utf8"))).match(/opencode\.gitconfig/g),
).toHaveLength(1)
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --get-all include.path`.text()),
).toBe("opencode.gitconfig\nfirst.gitconfig\nsecond.gitconfig\n")
).toBe("first.gitconfig\nsecond.gitconfig\nopencode.gitconfig\n")
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
const before = yield* git.tree.write(repository)
-38
View File
@@ -248,12 +248,6 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "lookup",
@@ -296,13 +290,6 @@ const mcp = Layer.mock(MCP.Service, {
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
@@ -997,31 +984,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_content_only",
name: "execute",
input: { code: "return await tools.demo.status({})" },
},
})
expect(execution).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
-41
View File
@@ -393,45 +393,4 @@ describe("fromPromise", () => {
expect(progress).toEqual([{ phase: "greeting" }])
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const promisePlugin = define({
id: "content-only-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "demo_status",
description: "Returns a status string",
input: Schema.Struct({}),
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
options: { codemode: true },
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const toolSet = yield* registry.snapshot()
const throughCodeMode = yield* toolSet.execute({
sessionID: Session.ID.make("ses_content_only_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_content_only_tool"),
call: {
type: "tool-call",
id: "call_content_only_tool",
name: "execute",
input: { code: "return await tools.demo_status({})" },
},
})
expect(throughCodeMode).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
})
+45
View File
@@ -127,6 +127,51 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("repairs existing storage with source repository semantics", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const file = path.join(project, "line-endings.txt")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(file, "before\n")
await initGit(project, true)
await $`git config core.autocrlf false`.cwd(project).quiet()
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
const source = yield* git.repo.discover(AbsolutePath.make(project))
if (!source) throw new Error("Repository not found")
const location = yield* Location.Service.pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
yield* git.repo.create({
worktree: source.worktree,
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot", location.project.id, Hash.fast(project))),
seed: source,
})
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(project).quiet()
await fs.rm(file)
await $`git checkout -- line-endings.txt`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
expect(before).toBeDefined()
yield* Effect.promise(() => fs.writeFile(file, "before\n"))
expect(yield* snapshot.capture()).toBe(before)
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+36
View File
@@ -93,6 +93,42 @@ describe("Vcs", () => {
),
)
it.live("respects repository line ending configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "line-endings.txt"), "before\n")
await commitAll(directory, "line endings")
await fs.rm(path.join(directory, "line-endings.txt"))
await $`git checkout -- line-endings.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("respects repository symlink configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
const blob = await $`printf target.txt | git hash-object -w --stdin`.cwd(directory).text()
await $`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(directory).quiet()
await $`git commit -m symlink`.cwd(directory).quiet()
await $`git config core.symlinks false`.cwd(directory).quiet()
await $`git checkout-index -f link.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {