mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-19 13:14:32 +00:00
Compare commits
4 Commits
refactor/d
...
effectify-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba44d37ac0 | ||
|
|
e2d4e60eab | ||
|
|
e21d892cff | ||
|
|
527956e3ec |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=",
|
||||
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=",
|
||||
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=",
|
||||
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y="
|
||||
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function init() {
|
||||
useKeyboard((evt) => {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
|
||||
@@ -17,21 +17,17 @@ export namespace Editor {
|
||||
await Filesystem.write(filepath, opts.value)
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
try {
|
||||
const parts = editor.split(" ")
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
return content || undefined
|
||||
} finally {
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
}
|
||||
const parts = editor.split(" ")
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
return content || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ServiceMap } from "effect";
|
||||
import type { Project } from "@/project/project";
|
||||
import { ServiceMap } from "effect"
|
||||
import type { Project } from "@/project/project"
|
||||
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string;
|
||||
readonly worktree: string;
|
||||
readonly project: Project.Info;
|
||||
}
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceContext extends ServiceMap.Service<
|
||||
InstanceContext,
|
||||
InstanceContext.Shape
|
||||
>()("opencode/InstanceContext") {}
|
||||
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
||||
"opencode/InstanceContext",
|
||||
) {}
|
||||
|
||||
@@ -1,83 +1,64 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect";
|
||||
import { FileService } from "@/file";
|
||||
import { FileTimeService } from "@/file/time";
|
||||
import { FileWatcherService } from "@/file/watcher";
|
||||
import { FormatService } from "@/format";
|
||||
import { PermissionService } from "@/permission/service";
|
||||
import { Instance } from "@/project/instance";
|
||||
import { VcsService } from "@/project/vcs";
|
||||
import { ProviderAuthService } from "@/provider/auth-service";
|
||||
import { QuestionService } from "@/question/service";
|
||||
import { SkillService } from "@/skill/skill";
|
||||
import { SnapshotService } from "@/snapshot";
|
||||
import { InstanceContext } from "./instance-context";
|
||||
import { registerDisposer } from "./instance-registry";
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { ProviderAuthService } from "@/provider/auth-service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { PermissionService } from "@/permission/service"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FormatService } from "@/format"
|
||||
import { FileService } from "@/file"
|
||||
import { SkillService } from "@/skill/skill"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export { InstanceContext } from "./instance-context";
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
| SnapshotService;
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||
// This should go away once the old Instance type is removed and lookup can load
|
||||
// the full context directly.
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of(Instance.current),
|
||||
);
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
Layer.fresh(SnapshotService.layer),
|
||||
).pipe(Layer.provide(ctx));
|
||||
function lookup(directory: string) {
|
||||
const project = Instance.project
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
export class Instances extends ServiceMap.Service<
|
||||
Instances,
|
||||
LayerMap.LayerMap<string, InstanceServices>
|
||||
>()("opencode/Instances") {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, {
|
||||
idleTimeToLive: Infinity,
|
||||
});
|
||||
const unregister = registerDisposer((directory) =>
|
||||
Effect.runPromise(layerMap.invalidate(directory)),
|
||||
);
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister));
|
||||
return Instances.of(layerMap);
|
||||
}),
|
||||
);
|
||||
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||
"opencode/Instances",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
}),
|
||||
)
|
||||
|
||||
static get(
|
||||
directory: string,
|
||||
): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(
|
||||
Instances.use((map) => Effect.succeed(map.get(directory))),
|
||||
);
|
||||
}
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory));
|
||||
}
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
const children = await fs.promises
|
||||
.readdir(base, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
@@ -448,7 +450,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
}
|
||||
|
||||
const init = Effect.fn("FileService.init")(function* () {
|
||||
yield* Effect.promise(() => kick())
|
||||
void kick()
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileService.status")(function* () {
|
||||
|
||||
@@ -185,10 +185,12 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const deploymentType = inputs.deploymentType || "github.com"
|
||||
|
||||
let domain = "github.com"
|
||||
let actualProvider = "github-copilot"
|
||||
|
||||
if (deploymentType === "enterprise") {
|
||||
const enterpriseUrl = inputs.enterpriseUrl
|
||||
domain = normalizeDomain(enterpriseUrl!)
|
||||
actualProvider = "github-copilot-enterprise"
|
||||
}
|
||||
|
||||
const urls = getUrls(domain)
|
||||
@@ -260,7 +262,8 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
expires: 0,
|
||||
}
|
||||
|
||||
if (deploymentType === "enterprise") {
|
||||
if (actualProvider === "github-copilot-enterprise") {
|
||||
result.provider = "github-copilot-enterprise"
|
||||
result.enterpriseUrl = domain
|
||||
}
|
||||
|
||||
|
||||
@@ -1,185 +1,163 @@
|
||||
import { GlobalBus } from "@/bus/global";
|
||||
import { disposeInstance } from "@/effect/instance-registry";
|
||||
import { Filesystem } from "@/util/filesystem";
|
||||
import { iife } from "@/util/iife";
|
||||
import { Log } from "@/util/log";
|
||||
import { Context } from "../util/context";
|
||||
import { Project } from "./project";
|
||||
import { State } from "./state";
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
|
||||
interface Context {
|
||||
directory: string;
|
||||
worktree: string;
|
||||
project: Project.Info;
|
||||
directory: string
|
||||
worktree: string
|
||||
project: Project.Info
|
||||
}
|
||||
const context = Context.create<Context>("instance");
|
||||
const cache = new Map<string, Promise<Context>>();
|
||||
const context = Context.create<Context>("instance")
|
||||
const cache = new Map<string, Promise<Context>>()
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
};
|
||||
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
});
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function boot(input: {
|
||||
directory: string;
|
||||
init?: () => Promise<any>;
|
||||
project?: Project.Info;
|
||||
worktree?: string;
|
||||
}) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(
|
||||
({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}),
|
||||
);
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.();
|
||||
});
|
||||
return ctx;
|
||||
});
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
return ctx
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Context>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory);
|
||||
throw error;
|
||||
});
|
||||
cache.set(directory, task);
|
||||
return task;
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
})
|
||||
cache.set(directory, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: {
|
||||
directory: string;
|
||||
init?: () => Promise<any>;
|
||||
fn: () => R;
|
||||
}): Promise<R> {
|
||||
const directory = Filesystem.resolve(input.directory);
|
||||
let existing = cache.get(directory);
|
||||
if (!existing) {
|
||||
Log.Default.info("creating instance", { directory });
|
||||
existing = track(
|
||||
directory,
|
||||
boot({
|
||||
directory,
|
||||
init: input.init,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const ctx = await existing;
|
||||
return context.provide(ctx, async () => {
|
||||
return input.fn();
|
||||
});
|
||||
},
|
||||
get current() {
|
||||
return context.use();
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory;
|
||||
},
|
||||
get worktree() {
|
||||
return context.use().worktree;
|
||||
},
|
||||
get project() {
|
||||
return context.use().project;
|
||||
},
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
containsPath(filepath: string) {
|
||||
if (Filesystem.contains(Instance.directory, filepath)) return true;
|
||||
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||
// Skip worktree check in this case to preserve external_directory permissions.
|
||||
if (Instance.worktree === "/") return false;
|
||||
return Filesystem.contains(Instance.worktree, filepath);
|
||||
},
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use();
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F;
|
||||
},
|
||||
state<S>(
|
||||
init: () => S,
|
||||
dispose?: (state: Awaited<S>) => Promise<void>,
|
||||
): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose);
|
||||
},
|
||||
async reload(input: {
|
||||
directory: string;
|
||||
init?: () => Promise<any>;
|
||||
project?: Project.Info;
|
||||
worktree?: string;
|
||||
}) {
|
||||
const directory = Filesystem.resolve(input.directory);
|
||||
Log.Default.info("reloading instance", { directory });
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
|
||||
cache.delete(directory);
|
||||
const next = track(directory, boot({ ...input, directory }));
|
||||
emit(directory);
|
||||
return await next;
|
||||
},
|
||||
async dispose() {
|
||||
const directory = Instance.directory;
|
||||
Log.Default.info("disposing instance", { directory });
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
|
||||
cache.delete(directory);
|
||||
emit(directory);
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all;
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
let existing = cache.get(directory)
|
||||
if (!existing) {
|
||||
Log.Default.info("creating instance", { directory })
|
||||
existing = track(
|
||||
directory,
|
||||
boot({
|
||||
directory,
|
||||
init: input.init,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const ctx = await existing
|
||||
return context.provide(ctx, async () => {
|
||||
return input.fn()
|
||||
})
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory
|
||||
},
|
||||
get worktree() {
|
||||
return context.use().worktree
|
||||
},
|
||||
get project() {
|
||||
return context.use().project
|
||||
},
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
containsPath(filepath: string) {
|
||||
if (Filesystem.contains(Instance.directory, filepath)) return true
|
||||
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||
// Skip worktree check in this case to preserve external_directory permissions.
|
||||
if (Instance.worktree === "/") return false
|
||||
return Filesystem.contains(Instance.worktree, filepath)
|
||||
},
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use()
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||
},
|
||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
const directory = Instance.directory
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
emit(directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
disposal.all = iife(async () => {
|
||||
Log.Default.info("disposing all instances");
|
||||
const entries = [...cache.entries()];
|
||||
for (const [key, value] of entries) {
|
||||
if (cache.get(key) !== value) continue;
|
||||
disposal.all = iife(async () => {
|
||||
Log.Default.info("disposing all instances")
|
||||
const entries = [...cache.entries()]
|
||||
for (const [key, value] of entries) {
|
||||
if (cache.get(key) !== value) continue
|
||||
|
||||
const ctx = await value.catch((error) => {
|
||||
Log.Default.warn("instance dispose failed", { key, error });
|
||||
return undefined;
|
||||
});
|
||||
const ctx = await value.catch((error) => {
|
||||
Log.Default.warn("instance dispose failed", { key, error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!ctx) {
|
||||
if (cache.get(key) === value) cache.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (!ctx) {
|
||||
if (cache.get(key) === value) cache.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cache.get(key) !== value) continue;
|
||||
if (cache.get(key) !== value) continue
|
||||
|
||||
await context.provide(ctx, async () => {
|
||||
await Instance.dispose();
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
disposal.all = undefined;
|
||||
});
|
||||
await context.provide(ctx, async () => {
|
||||
await Instance.dispose()
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
disposal.all = undefined
|
||||
})
|
||||
|
||||
return disposal.all;
|
||||
},
|
||||
};
|
||||
return disposal.all
|
||||
},
|
||||
}
|
||||
|
||||
@@ -197,6 +197,16 @@ export namespace Provider {
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
"github-copilot-enterprise": async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
azure: async (provider) => {
|
||||
const resource = iife(() => {
|
||||
const name = provider.options?.resourceName
|
||||
@@ -853,6 +863,20 @@ export namespace Provider {
|
||||
|
||||
const configProviders = Object.entries(config.provider ?? {})
|
||||
|
||||
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
|
||||
if (database["github-copilot"]) {
|
||||
const githubCopilot = database["github-copilot"]
|
||||
database["github-copilot-enterprise"] = {
|
||||
...githubCopilot,
|
||||
id: ProviderID.githubCopilotEnterprise,
|
||||
name: "GitHub Copilot Enterprise",
|
||||
models: mapValues(githubCopilot.models, (model) => ({
|
||||
...model,
|
||||
providerID: ProviderID.githubCopilotEnterprise,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||
const existing = providers[providerID]
|
||||
if (existing) {
|
||||
@@ -979,16 +1003,46 @@ export namespace Provider {
|
||||
const providerID = ProviderID.make(plugin.auth.provider)
|
||||
if (disabled.has(providerID)) continue
|
||||
|
||||
// For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
|
||||
let hasAuth = false
|
||||
const auth = await Auth.get(providerID)
|
||||
if (!auth) continue
|
||||
if (auth) hasAuth = true
|
||||
|
||||
// Special handling for github-copilot: also check for enterprise auth
|
||||
if (providerID === ProviderID.githubCopilot && !hasAuth) {
|
||||
const enterpriseAuth = await Auth.get("github-copilot-enterprise")
|
||||
if (enterpriseAuth) hasAuth = true
|
||||
}
|
||||
|
||||
if (!hasAuth) continue
|
||||
if (!plugin.auth.loader) continue
|
||||
|
||||
// Load for the main provider if auth exists
|
||||
if (auth) {
|
||||
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
||||
const opts = options ?? {}
|
||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||
mergeProvider(providerID, patch)
|
||||
}
|
||||
|
||||
// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
|
||||
if (providerID === ProviderID.githubCopilot) {
|
||||
const enterpriseProviderID = ProviderID.githubCopilotEnterprise
|
||||
if (!disabled.has(enterpriseProviderID)) {
|
||||
const enterpriseAuth = await Auth.get(enterpriseProviderID)
|
||||
if (enterpriseAuth) {
|
||||
const enterpriseOptions = await plugin.auth.loader(
|
||||
() => Auth.get(enterpriseProviderID) as any,
|
||||
database[enterpriseProviderID],
|
||||
)
|
||||
const opts = enterpriseOptions ?? {}
|
||||
const patch: Partial<Info> = providers[enterpriseProviderID]
|
||||
? { options: opts }
|
||||
: { source: "custom", options: opts }
|
||||
mergeProvider(enterpriseProviderID, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ProviderID = providerIdSchema.pipe(
|
||||
google: schema.makeUnsafe("google"),
|
||||
googleVertex: schema.makeUnsafe("google-vertex"),
|
||||
githubCopilot: schema.makeUnsafe("github-copilot"),
|
||||
githubCopilotEnterprise: schema.makeUnsafe("github-copilot-enterprise"),
|
||||
amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
|
||||
azure: schema.makeUnsafe("azure"),
|
||||
openrouter: schema.makeUnsafe("openrouter"),
|
||||
|
||||
@@ -1,516 +1,416 @@
|
||||
import {
|
||||
NodeChildProcessSpawner,
|
||||
NodeFileSystem,
|
||||
NodePath,
|
||||
} from "@effect/platform-node";
|
||||
import {
|
||||
Cause,
|
||||
Duration,
|
||||
Effect,
|
||||
FileSystem,
|
||||
Layer,
|
||||
Schedule,
|
||||
ServiceMap,
|
||||
Stream,
|
||||
} from "effect";
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { InstanceContext } from "@/effect/instance-context";
|
||||
import { runPromiseInstance } from "@/effect/runtime";
|
||||
import { Config } from "../config/config";
|
||||
import { Global } from "../global";
|
||||
import { Log } from "../util/log";
|
||||
|
||||
const log = Log.create({ service: "snapshot" });
|
||||
const PRUNE = "7.days";
|
||||
|
||||
// Common git config flags shared across snapshot operations
|
||||
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"];
|
||||
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE];
|
||||
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"];
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode;
|
||||
readonly text: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export namespace Snapshot {
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
});
|
||||
export type Patch = z.infer<typeof Patch>;
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const hour = 60 * 60 * 1000
|
||||
const prune = "7.days"
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
});
|
||||
export type FileDiff = z.infer<typeof FileDiff>;
|
||||
function args(git: string, cmd: string[]) {
|
||||
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
|
||||
}
|
||||
|
||||
// Promise facade — existing callers use these
|
||||
export function init() {
|
||||
void runPromiseInstance(SnapshotService.use((s) => s.init()));
|
||||
}
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "snapshot.cleanup",
|
||||
interval: hour,
|
||||
run: cleanup,
|
||||
scope: "instance",
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()));
|
||||
}
|
||||
export async function cleanup() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
const exists = await fs
|
||||
.stat(git)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.track()));
|
||||
}
|
||||
export async function track() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await Process.run(["git", "init"], {
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: git,
|
||||
GIT_WORK_TREE: Instance.worktree,
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)));
|
||||
}
|
||||
// Configure git to not convert line endings on Windows
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
|
||||
log.info("initialized")
|
||||
}
|
||||
await add(git)
|
||||
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||
return hash.trim()
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)));
|
||||
}
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
})
|
||||
export type Patch = z.infer<typeof Patch>
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)));
|
||||
}
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)));
|
||||
}
|
||||
// If git diff fails, return empty patch
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)));
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SnapshotService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>;
|
||||
readonly cleanup: () => Effect.Effect<void>;
|
||||
readonly track: () => Effect.Effect<string | undefined>;
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>;
|
||||
readonly restore: (snapshot: string) => Effect.Effect<void>;
|
||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>;
|
||||
readonly diff: (hash: string) => Effect.Effect<string>;
|
||||
readonly diffFull: (
|
||||
from: string,
|
||||
to: string,
|
||||
) => Effect.Effect<Snapshot.FileDiff[]>;
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapshotService extends ServiceMap.Service<
|
||||
SnapshotService,
|
||||
SnapshotService.Service
|
||||
>()("@opencode/Snapshot") {
|
||||
static readonly layer = Layer.effect(
|
||||
SnapshotService,
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceContext;
|
||||
const fileSystem = yield* FileSystem.FileSystem;
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
||||
const { directory, worktree, project } = ctx;
|
||||
const isGit = project.vcs === "git";
|
||||
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id);
|
||||
|
||||
const gitArgs = (cmd: string[]) => [
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"--work-tree",
|
||||
worktree,
|
||||
...cmd,
|
||||
];
|
||||
|
||||
// Run git with nothrow semantics — always returns a result, never fails
|
||||
const git = (
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string> },
|
||||
): Effect.Effect<GitResult> =>
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("git", args, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
});
|
||||
const handle = yield* spawner.spawn(command);
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
);
|
||||
const code = yield* handle.exitCode;
|
||||
return { code, text, stderr };
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// FileSystem helpers — orDie converts PlatformError to defects
|
||||
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie);
|
||||
const mkdir = (p: string) =>
|
||||
fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie);
|
||||
const writeFile = (p: string, content: string) =>
|
||||
fileSystem.writeFileString(p, content).pipe(Effect.orDie);
|
||||
const readFile = (p: string) =>
|
||||
fileSystem
|
||||
.readFileString(p)
|
||||
.pipe(Effect.catch(() => Effect.succeed("")));
|
||||
const removeFile = (p: string) =>
|
||||
fileSystem.remove(p).pipe(Effect.catch(() => Effect.void));
|
||||
|
||||
// --- internal Effect helpers ---
|
||||
|
||||
const isEnabled = Effect.gen(function* () {
|
||||
if (!isGit) return false;
|
||||
const cfg = yield* Effect.promise(() => Config.get());
|
||||
return cfg.snapshot !== false;
|
||||
});
|
||||
|
||||
const excludesPath = Effect.gen(function* () {
|
||||
const result = yield* git(
|
||||
["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
const file = result.text.trim();
|
||||
if (!file) return undefined;
|
||||
if (!(yield* exists(file))) return undefined;
|
||||
return file;
|
||||
});
|
||||
|
||||
const syncExclude = Effect.gen(function* () {
|
||||
const file = yield* excludesPath;
|
||||
const target = path.join(snapshotGit, "info", "exclude");
|
||||
yield* mkdir(path.join(snapshotGit, "info"));
|
||||
if (!file) {
|
||||
yield* writeFile(target, "");
|
||||
return;
|
||||
}
|
||||
const text = yield* readFile(file);
|
||||
yield* writeFile(target, text);
|
||||
});
|
||||
|
||||
const add = Effect.gen(function* () {
|
||||
yield* syncExclude;
|
||||
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory });
|
||||
});
|
||||
|
||||
// --- service methods ---
|
||||
|
||||
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
|
||||
if (!(yield* isEnabled)) return;
|
||||
if (!(yield* exists(snapshotGit))) return;
|
||||
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
|
||||
cwd: directory,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.info("cleanup", { prune: PRUNE });
|
||||
});
|
||||
|
||||
const track = Effect.fn("SnapshotService.track")(function* () {
|
||||
if (!(yield* isEnabled)) return undefined;
|
||||
const existed = yield* exists(snapshotGit);
|
||||
yield* mkdir(snapshotGit);
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
|
||||
});
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.autocrlf",
|
||||
"false",
|
||||
]);
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.longpaths",
|
||||
"true",
|
||||
]);
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.symlinks",
|
||||
"true",
|
||||
]);
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.fsmonitor",
|
||||
"false",
|
||||
]);
|
||||
log.info("initialized");
|
||||
}
|
||||
yield* add;
|
||||
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory });
|
||||
const hash = result.text.trim();
|
||||
log.info("tracking", { hash, cwd: directory, git: snapshotGit });
|
||||
return hash;
|
||||
});
|
||||
|
||||
const patch = Effect.fn("SnapshotService.patch")(function* (
|
||||
hash: string,
|
||||
) {
|
||||
yield* add;
|
||||
const result = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--name-only",
|
||||
hash,
|
||||
"--",
|
||||
".",
|
||||
]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code });
|
||||
return { hash, files: [] } as Snapshot.Patch;
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x: string) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
} as Snapshot.Patch;
|
||||
});
|
||||
|
||||
const restore = Effect.fn("SnapshotService.restore")(function* (
|
||||
snapshot: string,
|
||||
) {
|
||||
log.info("restore", { commit: snapshot });
|
||||
const result = yield* git(
|
||||
[...GIT_CORE, ...gitArgs(["read-tree", snapshot])],
|
||||
{ cwd: worktree },
|
||||
);
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git(
|
||||
[...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])],
|
||||
{ cwd: worktree },
|
||||
);
|
||||
if (checkout.code === 0) return;
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
});
|
||||
|
||||
const revert = Effect.fn("SnapshotService.revert")(function* (
|
||||
patches: Snapshot.Patch[],
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue;
|
||||
log.info("reverting", { file, hash: item.hash });
|
||||
const result = yield* git(
|
||||
[...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(worktree, file);
|
||||
const checkTree = yield* git(
|
||||
[
|
||||
...GIT_CORE,
|
||||
...gitArgs(["ls-tree", item.hash, "--", relativePath]),
|
||||
],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info(
|
||||
"file existed in snapshot but checkout failed, keeping",
|
||||
{ file },
|
||||
);
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file });
|
||||
yield* removeFile(file);
|
||||
}
|
||||
}
|
||||
seen.add(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
|
||||
yield* add;
|
||||
const result = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
return result.text.trim();
|
||||
});
|
||||
|
||||
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
const result: Snapshot.FileDiff[] = [];
|
||||
const status = new Map<string, "added" | "deleted" | "modified">();
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
from,
|
||||
to,
|
||||
"--",
|
||||
".",
|
||||
]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
);
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const [code, file] = line.split("\t");
|
||||
if (!code || !file) continue;
|
||||
const kind = code.startsWith("A")
|
||||
? "added"
|
||||
: code.startsWith("D")
|
||||
? "deleted"
|
||||
: "modified";
|
||||
status.set(file, kind);
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
"--numstat",
|
||||
from,
|
||||
to,
|
||||
"--",
|
||||
".",
|
||||
]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
);
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const [additions, deletions, file] = line.split("\t");
|
||||
const isBinaryFile = additions === "-" && deletions === "-";
|
||||
const [before, after] = isBinaryFile
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([
|
||||
...GIT_CFG,
|
||||
...gitArgs(["show", `${from}:${file}`]),
|
||||
]).pipe(Effect.map((r) => r.text)),
|
||||
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(
|
||||
Effect.map((r) => r.text),
|
||||
),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
);
|
||||
const added = isBinaryFile ? 0 : parseInt(additions!);
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions!);
|
||||
result.push({
|
||||
file: file!,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file!) ?? "modified",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Start hourly cleanup fiber — scoped to instance lifetime
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) });
|
||||
return Effect.void;
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.forkScoped,
|
||||
);
|
||||
|
||||
return SnapshotService.of({
|
||||
init: Effect.fn("SnapshotService.init")(function* () {}),
|
||||
cleanup,
|
||||
track,
|
||||
patch,
|
||||
restore,
|
||||
revert,
|
||||
diff,
|
||||
diffFull,
|
||||
});
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
);
|
||||
const files = result.text
|
||||
return {
|
||||
hash,
|
||||
files: files
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
const result = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code === 0) {
|
||||
const checkout = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr.toString(),
|
||||
stdout: checkout.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
const files = new Set<string>()
|
||||
const git = gitdir()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["checkout", item.hash, "--", file]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(Instance.worktree, file)
|
||||
const checkTree = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["ls-tree", item.hash, "--", relativePath]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||
file,
|
||||
})
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
await fs.unlink(file).catch(() => {})
|
||||
}
|
||||
}
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text.trim()
|
||||
}
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = gitdir()
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
).then((x) => x.text)
|
||||
|
||||
for (const line of statuses.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
for (const line of await Process.lines(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const before = isBinaryFile
|
||||
? ""
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${from}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const after = isBinaryFile
|
||||
? ""
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${to}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function gitdir() {
|
||||
const project = Instance.project
|
||||
return path.join(Global.Path.data, "snapshot", project.id)
|
||||
}
|
||||
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["add", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
const file = await excludes()
|
||||
const target = path.join(git, "info", "exclude")
|
||||
await fs.mkdir(path.join(git, "info"), { recursive: true })
|
||||
if (!file) {
|
||||
await Filesystem.write(target, "")
|
||||
return
|
||||
}
|
||||
const text = await Filesystem.readText(file).catch(() => "")
|
||||
|
||||
await Filesystem.write(target, text)
|
||||
}
|
||||
|
||||
async function excludes() {
|
||||
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
if (!file.trim()) return
|
||||
const exists = await fs
|
||||
.stat(file.trim())
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
return file.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { buffer } from "node:stream/consumers"
|
||||
|
||||
export namespace Process {
|
||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||
export type Shell = boolean | string
|
||||
|
||||
export interface Options {
|
||||
cwd?: string
|
||||
@@ -11,7 +10,6 @@ export namespace Process {
|
||||
stdin?: Stdio
|
||||
stdout?: Stdio
|
||||
stderr?: Stdio
|
||||
shell?: Shell
|
||||
abort?: AbortSignal
|
||||
kill?: NodeJS.Signals | number
|
||||
timeout?: number
|
||||
@@ -62,7 +60,6 @@ export namespace Process {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
shell: opts.shell,
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
|
||||
@@ -681,7 +681,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
// Give the background scan time to populate
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
@@ -695,7 +697,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
@@ -715,7 +718,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
@@ -729,7 +733,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
// Files don't end with /
|
||||
@@ -746,7 +751,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
// Directories end with /
|
||||
@@ -763,7 +769,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "file", limit: 2 })
|
||||
expect(result.length).toBeLessThanOrEqual(2)
|
||||
@@ -777,7 +784,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: ".hidden", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -9,19 +9,6 @@ import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
function gate() {
|
||||
let open!: () => void
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
open = resolve
|
||||
})
|
||||
return { open, wait }
|
||||
}
|
||||
|
||||
describe("file/time", () => {
|
||||
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
||||
|
||||
@@ -38,6 +25,7 @@ describe("file/time", () => {
|
||||
expect(before).toBeUndefined()
|
||||
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
|
||||
const after = await FileTime.get(sessionID, filepath)
|
||||
expect(after).toBeInstanceOf(Date)
|
||||
@@ -56,6 +44,7 @@ describe("file/time", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||
await Bun.sleep(10)
|
||||
|
||||
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||
@@ -74,10 +63,14 @@ describe("file/time", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
const first = await FileTime.get(sessionID, filepath)
|
||||
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
|
||||
FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
const second = await FileTime.get(sessionID, filepath)
|
||||
|
||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||
@@ -91,12 +84,12 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
@@ -119,14 +112,13 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(100)
|
||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
@@ -136,14 +128,13 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(100)
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
let error: Error | undefined
|
||||
try {
|
||||
@@ -200,25 +191,18 @@ describe("file/time", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const order: number[] = []
|
||||
const hold = gate()
|
||||
const ready = gate()
|
||||
|
||||
const op1 = FileTime.withLock(filepath, async () => {
|
||||
order.push(1)
|
||||
ready.open()
|
||||
await hold.wait
|
||||
await Bun.sleep(50)
|
||||
order.push(2)
|
||||
})
|
||||
|
||||
await ready.wait
|
||||
|
||||
const op2 = FileTime.withLock(filepath, async () => {
|
||||
order.push(3)
|
||||
order.push(4)
|
||||
})
|
||||
|
||||
hold.open()
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
expect(order).toEqual([1, 2, 3, 4])
|
||||
},
|
||||
@@ -235,21 +219,15 @@ describe("file/time", () => {
|
||||
fn: async () => {
|
||||
let started1 = false
|
||||
let started2 = false
|
||||
const hold = gate()
|
||||
const ready = gate()
|
||||
|
||||
const op1 = FileTime.withLock(filepath1, async () => {
|
||||
started1 = true
|
||||
ready.open()
|
||||
await hold.wait
|
||||
await Bun.sleep(50)
|
||||
expect(started2).toBe(true)
|
||||
})
|
||||
|
||||
await ready.wait
|
||||
|
||||
const op2 = FileTime.withLock(filepath2, async () => {
|
||||
started2 = true
|
||||
hold.open()
|
||||
})
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
@@ -287,12 +265,12 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
|
||||
const stats = Filesystem.stat(filepath)
|
||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
||||
@@ -307,17 +285,17 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
FileTime.read(sessionID, filepath)
|
||||
await Bun.sleep(10)
|
||||
|
||||
const originalStat = Filesystem.stat(filepath)
|
||||
|
||||
await Bun.sleep(100)
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
const newStat = Filesystem.stat(filepath)
|
||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ConfigProvider, Layer, ManagedRuntime } from "effect";
|
||||
import { InstanceContext } from "../../src/effect/instance-context";
|
||||
import { Instance } from "../../src/project/instance";
|
||||
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
|
||||
import { InstanceContext } from "../../src/effect/instance-context"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
/** ConfigProvider that enables the experimental file watcher. */
|
||||
export const watcherConfigLayer = ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
);
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Boot an Instance with the given service layers and run `body` with
|
||||
@@ -19,35 +19,29 @@ export const watcherConfigLayer = ConfigProvider.layer(
|
||||
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
|
||||
*/
|
||||
export function withServices<S>(
|
||||
directory: string,
|
||||
layer: Layer.Layer<S, any, InstanceContext>,
|
||||
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
||||
options?: { provide?: Layer.Layer<never>[] },
|
||||
directory: string,
|
||||
layer: Layer.Layer<S, any, InstanceContext>,
|
||||
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
||||
options?: { provide?: Layer.Layer<never>[] },
|
||||
) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({
|
||||
directory: Instance.directory,
|
||||
worktree: Instance.worktree,
|
||||
project: Instance.project,
|
||||
}),
|
||||
);
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
|
||||
Layer.provide(ctx),
|
||||
) as any;
|
||||
if (options?.provide) {
|
||||
for (const l of options.provide) {
|
||||
resolved = resolved.pipe(Layer.provide(l)) as any;
|
||||
}
|
||||
}
|
||||
const rt = ManagedRuntime.make(resolved);
|
||||
try {
|
||||
await body(rt);
|
||||
} finally {
|
||||
await rt.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
|
||||
)
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
|
||||
if (options?.provide) {
|
||||
for (const l of options.provide) {
|
||||
resolved = resolved.pipe(Layer.provide(l)) as any
|
||||
}
|
||||
}
|
||||
const rt = ManagedRuntime.make(resolved)
|
||||
try {
|
||||
await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome
|
||||
// Set test managed config directory to isolate tests from system managed settings
|
||||
const testManagedConfigDir = path.join(dir, "managed")
|
||||
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
|
||||
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
|
||||
|
||||
// Write the cache version file to prevent global/index.ts from clearing the cache
|
||||
const cacheDir = path.join(dir, "cache", "opencode")
|
||||
|
||||
@@ -18,11 +18,6 @@ const ctx = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
@@ -116,7 +111,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
@@ -143,7 +138,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
@@ -191,7 +186,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
@@ -235,17 +230,18 @@ describe("tool.edit", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Wait a bit to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
// Try to edit with the new content
|
||||
const edit = await EditTool.init()
|
||||
@@ -271,7 +267,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
@@ -298,7 +294,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
@@ -336,7 +332,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
@@ -362,7 +358,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
@@ -411,7 +407,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, dirpath)
|
||||
FileTime.read(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
@@ -436,7 +432,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
@@ -507,7 +503,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath,
|
||||
@@ -648,7 +644,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
|
||||
@@ -663,7 +659,7 @@ describe("tool.edit", () => {
|
||||
)
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = edit.execute(
|
||||
{
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("tool.write", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
@@ -128,7 +128,7 @@ describe("tool.write", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
@@ -306,7 +306,7 @@ describe("tool.write", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
await FileTime.read(ctx.sessionID, readonlyPath)
|
||||
FileTime.read(ctx.sessionID, readonlyPath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user