Procházet zdrojové kódy

refactor(truncation): centralize output and effect test helpers

Kit Langton před 1 měsícem
rodič
revize
105606e389

+ 12 - 0
packages/opencode/src/permission/evaluate.ts

@@ -0,0 +1,12 @@
+import { Log } from "@/util/log"
+import { Wildcard } from "@/util/wildcard"
+import type { Rule, Ruleset } from "./service"
+
+const log = Log.create({ service: "permission" })
+
+export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+  const rules = rulesets.flat()
+  log.info("evaluate", { permission, pattern, ruleset: rules })
+  const match = rules.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern))
+  return match ?? { action: "ask", permission, pattern: "*" }
+}

+ 2 - 1
packages/opencode/src/permission/next.ts

@@ -3,6 +3,7 @@ import { Config } from "@/config/config"
 import { fn } from "@/util/fn"
 import { Wildcard } from "@/util/wildcard"
 import os from "os"
+import { evaluate as run } from "./evaluate"
 import * as S from "./service"
 
 export namespace PermissionNext {
@@ -66,7 +67,7 @@ export namespace PermissionNext {
   }
 
   export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-    return S.evaluate(permission, pattern, ...rulesets)
+    return run(permission, pattern, ...rulesets)
   }
 
   const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]

+ 1 - 9
packages/opencode/src/permission/service.ts

@@ -9,6 +9,7 @@ import { Log } from "@/util/log"
 import { Wildcard } from "@/util/wildcard"
 import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import z from "zod"
+import { evaluate } from "./evaluate"
 import { PermissionID } from "./schema"
 
 const log = Log.create({ service: "permission" })
@@ -240,12 +241,3 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
     }),
   )
 }
-
-export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-  const merged = rulesets.flat()
-  log.info("evaluate", { permission, pattern, ruleset: merged })
-  const match = merged.findLast(
-    (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
-  )
-  return match ?? { action: "ask", permission, pattern: "*" }
-}

+ 0 - 2
packages/opencode/src/project/bootstrap.ts

@@ -10,7 +10,6 @@ import { Instance } from "./instance"
 import { VcsService } from "./vcs"
 import { Log } from "@/util/log"
 import { ShareNext } from "@/share/share-next"
-import { Snapshot } from "../snapshot"
 import { runPromiseInstance } from "@/effect/runtime"
 
 export async function InstanceBootstrap() {
@@ -22,7 +21,6 @@ export async function InstanceBootstrap() {
   await runPromiseInstance(FileWatcherService.use((service) => service.init()))
   File.init()
   await runPromiseInstance(VcsService.use((s) => s.init()))
-  Snapshot.init()
 
   Bus.subscribe(Command.Event.Executed, async (payload) => {
     if (payload.properties.name === Command.Default.INIT) {

+ 361 - 503
packages/opencode/src/snapshot/index.ts

@@ -1,516 +1,374 @@
-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";
+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"];
+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;
+  readonly code: ChildProcessSpawner.ExitCode
+  readonly text: string
+  readonly stderr: string
 }
 
 export namespace Snapshot {
-	export const Patch = z.object({
-		hash: z.string(),
-		files: z.string().array(),
-	});
-	export type Patch = z.infer<typeof Patch>;
-
-	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>;
-
-	// Promise facade — existing callers use these
-	export function init() {
-		void runPromiseInstance(SnapshotService.use((s) => s.init()));
-	}
-
-	export async function cleanup() {
-		return runPromiseInstance(SnapshotService.use((s) => s.cleanup()));
-	}
-
-	export async function track() {
-		return runPromiseInstance(SnapshotService.use((s) => s.track()));
-	}
-
-	export async function patch(hash: string) {
-		return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)));
-	}
-
-	export async function restore(snapshot: string) {
-		return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)));
-	}
-
-	export async function revert(patches: Patch[]) {
-		return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)));
-	}
-
-	export async function diff(hash: string) {
-		return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)));
-	}
-
-	export async function diffFull(from: string, to: string) {
-		return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)));
-	}
+  export const Patch = z.object({
+    hash: z.string(),
+    files: z.string().array(),
+  })
+  export type Patch = z.infer<typeof Patch>
+
+  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 cleanup() {
+    return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
+  }
+
+  export async function track() {
+    return runPromiseInstance(SnapshotService.use((s) => s.track()))
+  }
+
+  export async function patch(hash: string) {
+    return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
+  }
+
+  export async function restore(snapshot: string) {
+    return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
+  }
+
+  export async function revert(patches: Patch[]) {
+    return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
+  }
+
+  export async function diff(hash: string) {
+    return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
+  }
+
+  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 interface Service {
+    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),
-	);
+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 = Effect.fnUntraced(
+        function* (args: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+          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 }
+        },
+        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: [] }
+        }
+
+        const files = result.text
+          .trim()
+          .split("\n")
+          .map((x: string) => x.trim())
+          .filter(Boolean)
+          .map((x: string) => path.join(worktree, x).replaceAll("\\", "/"))
+
+        return { hash, files }
+      })
+
+      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 delayed 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.delay(Duration.minutes(1)),
+        Effect.forkScoped,
+      )
+
+      return SnapshotService.of({
+        cleanup,
+        track,
+        patch,
+        restore,
+        revert,
+        diff,
+        diffFull,
+      })
+    }),
+  ).pipe(
+    Layer.provide(NodeChildProcessSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
 }

+ 83 - 2
packages/opencode/src/tool/truncate-service.ts

@@ -2,15 +2,35 @@ import path from "path"
 import { Log } from "../util/log"
 import { TRUNCATION_DIR } from "./truncation-dir"
 import { Identifier } from "../id/id"
+import type { Agent } from "../agent/agent"
+import { evaluate } from "../permission/evaluate"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
+import { ToolID } from "./schema"
 
 const log = Log.create({ service: "truncation" })
 const RETENTION = Duration.days(7)
 
+export const MAX_LINES = 2000
+export const MAX_BYTES = 50 * 1024
+
+export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+export interface Options {
+  maxLines?: number
+  maxBytes?: number
+  direction?: "head" | "tail"
+}
+
+function hasTaskTool(agent?: Agent.Info) {
+  if (!agent?.permission) return false
+  return evaluate("task", "*", agent.permission).action !== "deny"
+}
+
 export namespace TruncateService {
   export interface Service {
     readonly cleanup: () => Effect.Effect<void>
+    readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
   }
 }
 
@@ -36,17 +56,78 @@ export class TruncateService extends ServiceMap.Service<TruncateService, Truncat
         }
       })
 
-      // Start hourly cleanup — scoped to runtime lifetime
+      const output = Effect.fn("TruncateService.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
+        const maxLines = options.maxLines ?? MAX_LINES
+        const maxBytes = options.maxBytes ?? MAX_BYTES
+        const direction = options.direction ?? "head"
+        const lines = text.split("\n")
+        const totalBytes = Buffer.byteLength(text, "utf-8")
+
+        if (lines.length <= maxLines && totalBytes <= maxBytes) {
+          return { content: text, truncated: false } as const
+        }
+
+        const out: string[] = []
+        let i = 0
+        let bytes = 0
+        let hitBytes = false
+
+        if (direction === "head") {
+          for (i = 0; i < lines.length && i < maxLines; i++) {
+            const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+            if (bytes + size > maxBytes) {
+              hitBytes = true
+              break
+            }
+            out.push(lines[i])
+            bytes += size
+          }
+        } else {
+          for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+            const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+            if (bytes + size > maxBytes) {
+              hitBytes = true
+              break
+            }
+            out.unshift(lines[i])
+            bytes += size
+          }
+        }
+
+        const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+        const unit = hitBytes ? "bytes" : "lines"
+        const preview = out.join("\n")
+        const file = path.join(TRUNCATION_DIR, ToolID.ascending())
+
+        yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
+        yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+
+        const hint = hasTaskTool(agent)
+          ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+          : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+
+        return {
+          content:
+            direction === "head"
+              ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+              : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
+          truncated: true,
+          outputPath: file,
+        } as const
+      })
+
+      // Start delayed hourly cleanup — scoped to runtime lifetime
       yield* cleanup().pipe(
         Effect.catchCause((cause) => {
           log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
           return Effect.void
         }),
         Effect.repeat(Schedule.spaced(Duration.hours(1))),
+        Effect.delay(Duration.minutes(1)),
         Effect.forkScoped,
       )
 
-      return TruncateService.of({ cleanup })
+      return TruncateService.of({ cleanup, output })
     }),
   ).pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
 }

+ 6 - 76
packages/opencode/src/tool/truncation.ts

@@ -1,91 +1,21 @@
 import path from "path"
-import { PermissionNext } from "../permission/next"
 import { TRUNCATION_DIR } from "./truncation-dir"
 import type { Agent } from "../agent/agent"
-import { Filesystem } from "../util/filesystem"
-import { ToolID } from "./schema"
-import { TruncateService } from "./truncate-service"
 import { runtime } from "@/effect/runtime"
+import * as S from "./truncate-service"
 
 
 export namespace Truncate {
-  export const MAX_LINES = 2000
-  export const MAX_BYTES = 50 * 1024
+  export const MAX_LINES = S.MAX_LINES
+  export const MAX_BYTES = S.MAX_BYTES
   export const DIR = TRUNCATION_DIR
   export const GLOB = path.join(TRUNCATION_DIR, "*")
 
-  export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+  export type Result = S.Result
 
-  export interface Options {
-    maxLines?: number
-    maxBytes?: number
-    direction?: "head" | "tail"
-  }
-
-  export async function cleanup() {
-    return runtime.runPromise(TruncateService.use((s) => s.cleanup()))
-  }
-
-  function hasTaskTool(agent?: Agent.Info): boolean {
-    if (!agent?.permission) return false
-    const rule = PermissionNext.evaluate("task", "*", agent.permission)
-    return rule.action !== "deny"
-  }
+  export type Options = S.Options
 
   export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
-    const maxLines = options.maxLines ?? MAX_LINES
-    const maxBytes = options.maxBytes ?? MAX_BYTES
-    const direction = options.direction ?? "head"
-    const lines = text.split("\n")
-    const totalBytes = Buffer.byteLength(text, "utf-8")
-
-    if (lines.length <= maxLines && totalBytes <= maxBytes) {
-      return { content: text, truncated: false }
-    }
-
-    const out: string[] = []
-    let i = 0
-    let bytes = 0
-    let hitBytes = false
-
-    if (direction === "head") {
-      for (i = 0; i < lines.length && i < maxLines; i++) {
-        const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
-        if (bytes + size > maxBytes) {
-          hitBytes = true
-          break
-        }
-        out.push(lines[i])
-        bytes += size
-      }
-    } else {
-      for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
-        const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
-        if (bytes + size > maxBytes) {
-          hitBytes = true
-          break
-        }
-        out.unshift(lines[i])
-        bytes += size
-      }
-    }
-
-    const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
-    const unit = hitBytes ? "bytes" : "lines"
-    const preview = out.join("\n")
-
-    const id = ToolID.ascending()
-    const filepath = path.join(DIR, id)
-    await Filesystem.write(filepath, text)
-
-    const hint = hasTaskTool(agent)
-      ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
-      : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
-    const message =
-      direction === "head"
-        ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
-        : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
-
-    return { content: message, truncated: true, outputPath: filepath }
+    return runtime.runPromise(S.TruncateService.use((s) => s.output(text, options, agent)))
   }
 }

+ 13 - 25
packages/opencode/test/account/repo.test.ts

@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
 import { AccountRepo } from "../../src/account/repo"
 import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
 import { Database } from "../../src/storage/db"
-import { testEffect } from "../fixture/effect"
+import { testEffect } from "../lib/effect"
 
 const truncate = Layer.effectDiscard(
   Effect.sync(() => {
@@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard(
 
 const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
 
-it.effect(
-  "list returns empty when no accounts exist",
+it.effect("list returns empty when no accounts exist", () =>
   Effect.gen(function* () {
     const accounts = yield* AccountRepo.use((r) => r.list())
     expect(accounts).toEqual([])
   }),
 )
 
-it.effect(
-  "active returns none when no accounts exist",
+it.effect("active returns none when no accounts exist", () =>
   Effect.gen(function* () {
     const active = yield* AccountRepo.use((r) => r.active())
     expect(Option.isNone(active)).toBe(true)
   }),
 )
 
-it.effect(
-  "persistAccount inserts and getRow retrieves",
+it.effect("persistAccount inserts and getRow retrieves", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
     yield* AccountRepo.use((r) =>
@@ -59,8 +56,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "persistAccount sets the active account and org",
+it.effect("persistAccount sets the active account and org", () =>
   Effect.gen(function* () {
     const id1 = AccountID.make("user-1")
     const id2 = AccountID.make("user-2")
@@ -97,8 +93,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "list returns all accounts",
+it.effect("list returns all accounts", () =>
   Effect.gen(function* () {
     const id1 = AccountID.make("user-1")
     const id2 = AccountID.make("user-2")
@@ -133,8 +128,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "remove deletes an account",
+it.effect("remove deletes an account", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -157,8 +151,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "use stores the selected org and marks the account active",
+it.effect("use stores the selected org and marks the account active", () =>
   Effect.gen(function* () {
     const id1 = AccountID.make("user-1")
     const id2 = AccountID.make("user-2")
@@ -198,8 +191,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "persistToken updates token fields",
+it.effect("persistToken updates token fields", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -233,8 +225,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "persistToken with no expiry sets token_expiry to null",
+it.effect("persistToken with no expiry sets token_expiry to null", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -264,8 +255,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "persistAccount upserts on conflict",
+it.effect("persistAccount upserts on conflict", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -305,8 +295,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "remove clears active state when deleting the active account",
+it.effect("remove clears active state when deleting the active account", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -329,8 +318,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "getRow returns none for nonexistent account",
+it.effect("getRow returns none for nonexistent account", () =>
   Effect.gen(function* () {
     const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
     expect(Option.isNone(row)).toBe(true)

+ 13 - 19
packages/opencode/test/account/service.test.ts

@@ -1,12 +1,12 @@
 import { expect } from "bun:test"
-import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
+import { Duration, Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 
 import { AccountRepo } from "../../src/account/repo"
 import { AccountService } from "../../src/account/service"
 import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
 import { Database } from "../../src/storage/db"
-import { testEffect } from "../fixture/effect"
+import { testEffect } from "../lib/effect"
 
 const truncate = Layer.effectDiscard(
   Effect.sync(() => {
@@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org)
 
 const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
 
-it.effect(
-  "orgsByAccount groups orgs per account",
+it.effect("orgsByAccount groups orgs per account", () =>
   Effect.gen(function* () {
     yield* AccountRepo.use((r) =>
       r.persistAccount({
@@ -61,10 +60,10 @@ it.effect(
       }),
     )
 
-    const seen = yield* Ref.make<string[]>([])
+    const seen: Array<string> = []
     const client = HttpClient.make((req) =>
       Effect.gen(function* () {
-        yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
+        seen.push(`${req.method} ${req.url}`)
 
         if (req.url === "https://one.example.com/api/orgs") {
           return json(req, [org("org-1", "One")])
@@ -84,15 +83,14 @@ it.effect(
       [AccountID.make("user-1"), [OrgID.make("org-1")]],
       [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
     ])
-    expect(yield* Ref.get(seen)).toEqual([
+    expect(seen).toEqual([
       "GET https://one.example.com/api/orgs",
       "GET https://two.example.com/api/orgs",
     ])
   }),
 )
 
-it.effect(
-  "token refresh persists the new token",
+it.effect("token refresh persists the new token", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -133,8 +131,7 @@ it.effect(
   }),
 )
 
-it.effect(
-  "config sends the selected org header",
+it.effect("config sends the selected org header", () =>
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
 
@@ -150,13 +147,11 @@ it.effect(
       }),
     )
 
-    const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
+    const seen: { auth?: string; org?: string } = {}
     const client = HttpClient.make((req) =>
       Effect.gen(function* () {
-        yield* Ref.set(seen, {
-          auth: req.headers.authorization,
-          org: req.headers["x-org-id"],
-        })
+        seen.auth = req.headers.authorization
+        seen.org = req.headers["x-org-id"]
 
         if (req.url === "https://one.example.com/api/config") {
           return json(req, { config: { theme: "light", seats: 5 } })
@@ -169,15 +164,14 @@ it.effect(
     const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
 
     expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
-    expect(yield* Ref.get(seen)).toEqual({
+    expect(seen).toEqual({
       auth: "Bearer at_1",
       org: "org-9",
     })
   }),
 )
 
-it.effect(
-  "poll stores the account and first org on success",
+it.effect("poll stores the account and first org on success", () =>
   Effect.gen(function* () {
     const login = new Login({
       code: DeviceCode.make("device-code"),

+ 0 - 7
packages/opencode/test/fixture/effect.ts

@@ -1,7 +0,0 @@
-import { test } from "bun:test"
-import { Effect, Layer } from "effect"
-
-export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
-  effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
-    test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
-})

+ 37 - 0
packages/opencode/test/lib/effect.ts

@@ -0,0 +1,37 @@
+import { test, type TestOptions } from "bun:test"
+import { Cause, Effect, Exit, Layer } from "effect"
+import type * as Scope from "effect/Scope"
+import * as TestConsole from "effect/testing/TestConsole"
+
+type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
+const env = TestConsole.layer
+
+const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
+
+const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
+  Effect.gen(function* () {
+    const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
+    if (Exit.isFailure(exit)) {
+      for (const err of Cause.prettyErrors(exit.cause)) {
+        yield* Effect.logError(err)
+      }
+    }
+    return yield* exit
+  }).pipe(Effect.runPromise)
+
+const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
+  const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
+    test(name, () => run(value, layer), opts)
+
+  effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
+    test.only(name, () => run(value, layer), opts)
+
+  effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
+    test.skip(name, () => run(value, layer), opts)
+
+  return { effect }
+}
+
+export const it = make(env)
+
+export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))

+ 10 - 0
packages/opencode/test/lib/filesystem.ts

@@ -0,0 +1,10 @@
+import path from "path"
+import { Effect, FileSystem } from "effect"
+
+export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
+  const fs = yield* FileSystem.FileSystem
+  yield* fs.makeDirectory(path.dirname(file), { recursive: true })
+  yield* fs.writeFileString(file, text)
+  yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
+  return file
+})

+ 20 - 28
packages/opencode/test/tool/truncation.test.ts

@@ -1,9 +1,13 @@
-import { describe, test, expect, afterAll } from "bun:test"
+import { describe, test, expect } from "bun:test"
+import { NodeFileSystem } from "@effect/platform-node"
+import { Effect, FileSystem, Layer } from "effect"
 import { Truncate } from "../../src/tool/truncation"
+import { TruncateService } from "../../src/tool/truncate-service"
 import { Identifier } from "../../src/id/id"
 import { Filesystem } from "../../src/util/filesystem"
-import fs from "fs/promises"
 import path from "path"
+import { testEffect } from "../lib/effect"
+import { writeFileStringScoped } from "../lib/filesystem"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 
@@ -125,36 +129,24 @@ describe("Truncate", () => {
 
   describe("cleanup", () => {
     const DAY_MS = 24 * 60 * 60 * 1000
-    let oldFile: string
-    let recentFile: string
+    const it = testEffect(Layer.mergeAll(TruncateService.layer, NodeFileSystem.layer))
 
-    afterAll(async () => {
-      await fs.unlink(oldFile).catch(() => {})
-      await fs.unlink(recentFile).catch(() => {})
-    })
-
-    test("deletes files older than 7 days and preserves recent files", async () => {
-      await fs.mkdir(Truncate.DIR, { recursive: true })
+    it.effect("deletes files older than 7 days and preserves recent files", () =>
+      Effect.gen(function* () {
+        const fs = yield* FileSystem.FileSystem
 
-      // Create an old file (10 days ago)
-      const oldTimestamp = Date.now() - 10 * DAY_MS
-      const oldId = Identifier.create("tool", false, oldTimestamp)
-      oldFile = path.join(Truncate.DIR, oldId)
-      await Filesystem.write(oldFile, "old content")
+        yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
 
-      // Create a recent file (3 days ago)
-      const recentTimestamp = Date.now() - 3 * DAY_MS
-      const recentId = Identifier.create("tool", false, recentTimestamp)
-      recentFile = path.join(Truncate.DIR, recentId)
-      await Filesystem.write(recentFile, "recent content")
+        const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
+        const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
 
-      await Truncate.cleanup()
+        yield* writeFileStringScoped(old, "old content")
+        yield* writeFileStringScoped(recent, "recent content")
+        yield* TruncateService.use((s) => s.cleanup())
 
-      // Old file should be deleted
-      expect(await Filesystem.exists(oldFile)).toBe(false)
-
-      // Recent file should still exist
-      expect(await Filesystem.exists(recentFile)).toBe(true)
-    })
+        expect(yield* fs.exists(old)).toBe(false)
+        expect(yield* fs.exists(recent)).toBe(true)
+      }),
+    )
   })
 })