mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:41:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1170776f8c |
@@ -1,5 +1,5 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Project } from "../../../project/project"
|
import { Project } from "../../../project"
|
||||||
import { Log } from "../../../util/log"
|
import { Log } from "../../../util/log"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Session } from "../../session"
|
|||||||
import { bootstrap } from "../bootstrap"
|
import { bootstrap } from "../bootstrap"
|
||||||
import { Database } from "../../storage/db"
|
import { Database } from "../../storage/db"
|
||||||
import { SessionTable } from "../../session/session.sql"
|
import { SessionTable } from "../../session/session.sql"
|
||||||
import { Project } from "../../project/project"
|
import { Project } from "../../project"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import z from "zod"
|
|||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Database, asc, eq, inArray } from "@/storage/db"
|
import { Database, asc, eq, inArray } from "@/storage/db"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ import { Command } from "@/command"
|
|||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project"
|
||||||
import { Worktree } from "@/worktree"
|
import { Worktree } from "@/worktree"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { FileWatcher } from "@/file/watcher"
|
|||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Observability } from "./observability"
|
import { Observability } from "./observability"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Format } from "../format"
|
|||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { Snapshot } from "../snapshot"
|
import { Snapshot } from "../snapshot"
|
||||||
import { Project } from "./project"
|
import { Project } from "."
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "."
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * as Vcs from "./vcs"
|
||||||
|
export * as Project from "./project"
|
||||||
@@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { LocalContext } from "../util/local-context"
|
import { LocalContext } from "../util/local-context"
|
||||||
import { Project } from "./project"
|
import { Project } from "."
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
export interface InstanceContext {
|
export interface InstanceContext {
|
||||||
|
|||||||
@@ -14,474 +14,472 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
|
||||||
export namespace Project {
|
const log = Log.create({ service: "project" })
|
||||||
const log = Log.create({ service: "project" })
|
|
||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
id: ProjectID.zod,
|
id: ProjectID.zod,
|
||||||
worktree: z.string(),
|
worktree: z.string(),
|
||||||
vcs: z.literal("git").optional(),
|
vcs: z.literal("git").optional(),
|
||||||
name: z.string().optional(),
|
|
||||||
icon: z
|
|
||||||
.object({
|
|
||||||
url: z.string().optional(),
|
|
||||||
override: z.string().optional(),
|
|
||||||
color: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
commands: z
|
|
||||||
.object({
|
|
||||||
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
time: z.object({
|
|
||||||
created: z.number(),
|
|
||||||
updated: z.number(),
|
|
||||||
initialized: z.number().optional(),
|
|
||||||
}),
|
|
||||||
sandboxes: z.array(z.string()),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Project",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Updated: BusEvent.define("project.updated", Info),
|
|
||||||
}
|
|
||||||
|
|
||||||
type Row = typeof ProjectTable.$inferSelect
|
|
||||||
|
|
||||||
export function fromRow(row: Row): Info {
|
|
||||||
const icon =
|
|
||||||
row.icon_url || row.icon_color
|
|
||||||
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
|
|
||||||
: undefined
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
worktree: row.worktree,
|
|
||||||
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
|
||||||
name: row.name ?? undefined,
|
|
||||||
icon,
|
|
||||||
time: {
|
|
||||||
created: row.time_created,
|
|
||||||
updated: row.time_updated,
|
|
||||||
initialized: row.time_initialized ?? undefined,
|
|
||||||
},
|
|
||||||
sandboxes: row.sandboxes,
|
|
||||||
commands: row.commands ?? undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateInput = z.object({
|
|
||||||
projectID: ProjectID.zod,
|
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
icon: Info.shape.icon.optional(),
|
icon: z
|
||||||
commands: Info.shape.commands.optional(),
|
.object({
|
||||||
|
url: z.string().optional(),
|
||||||
|
override: z.string().optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
commands: z
|
||||||
|
.object({
|
||||||
|
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
time: z.object({
|
||||||
|
created: z.number(),
|
||||||
|
updated: z.number(),
|
||||||
|
initialized: z.number().optional(),
|
||||||
|
}),
|
||||||
|
sandboxes: z.array(z.string()),
|
||||||
})
|
})
|
||||||
export type UpdateInput = z.infer<typeof UpdateInput>
|
.meta({
|
||||||
|
ref: "Project",
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export const Event = {
|
||||||
// Effect service
|
Updated: BusEvent.define("project.updated", Info),
|
||||||
// ---------------------------------------------------------------------------
|
}
|
||||||
|
|
||||||
export interface Interface {
|
type Row = typeof ProjectTable.$inferSelect
|
||||||
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
|
||||||
readonly discover: (input: Info) => Effect.Effect<void>
|
export function fromRow(row: Row): Info {
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
const icon =
|
||||||
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
|
row.icon_url || row.icon_color
|
||||||
readonly update: (input: UpdateInput) => Effect.Effect<Info>
|
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
|
||||||
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
|
: undefined
|
||||||
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
|
return {
|
||||||
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
|
id: row.id,
|
||||||
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
worktree: row.worktree,
|
||||||
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
||||||
|
name: row.name ?? undefined,
|
||||||
|
icon,
|
||||||
|
time: {
|
||||||
|
created: row.time_created,
|
||||||
|
updated: row.time_updated,
|
||||||
|
initialized: row.time_initialized ?? undefined,
|
||||||
|
},
|
||||||
|
sandboxes: row.sandboxes,
|
||||||
|
commands: row.commands ?? undefined,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
export const UpdateInput = z.object({
|
||||||
|
projectID: ProjectID.zod,
|
||||||
|
name: z.string().optional(),
|
||||||
|
icon: Info.shape.icon.optional(),
|
||||||
|
commands: Info.shape.commands.optional(),
|
||||||
|
})
|
||||||
|
export type UpdateInput = z.infer<typeof UpdateInput>
|
||||||
|
|
||||||
type GitResult = { code: number; text: string; stderr: string }
|
// ---------------------------------------------------------------------------
|
||||||
|
// Effect service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
export interface Interface {
|
||||||
Service,
|
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
||||||
never,
|
readonly discover: (input: Info) => Effect.Effect<void>
|
||||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
> = Layer.effect(
|
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
|
||||||
Service,
|
readonly update: (input: UpdateInput) => Effect.Effect<Info>
|
||||||
Effect.gen(function* () {
|
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
|
||||||
const fs = yield* AppFileSystem.Service
|
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
|
||||||
const pathSvc = yield* Path.Path
|
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||||
|
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||||
function* (args: string[], opts?: { cwd?: string }) {
|
|
||||||
const handle = yield* spawner.spawn(
|
type GitResult = { code: number; text: string; stderr: string }
|
||||||
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
|
||||||
)
|
export const layer: Layer.Layer<
|
||||||
const [text, stderr] = yield* Effect.all(
|
Service,
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
never,
|
||||||
{ concurrency: 2 },
|
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||||
)
|
> = Layer.effect(
|
||||||
const code = yield* handle.exitCode
|
Service,
|
||||||
return { code, text, stderr } satisfies GitResult
|
Effect.gen(function* () {
|
||||||
},
|
const fs = yield* AppFileSystem.Service
|
||||||
Effect.scoped,
|
const pathSvc = yield* Path.Path
|
||||||
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
|
const git = Effect.fnUntraced(
|
||||||
|
function* (args: string[], opts?: { cwd?: string }) {
|
||||||
|
const handle = yield* spawner.spawn(
|
||||||
|
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||||
|
)
|
||||||
|
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 } satisfies GitResult
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||||
|
Effect.sync(() => Database.use(fn))
|
||||||
|
|
||||||
|
const emitUpdated = (data: Info) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: "global",
|
||||||
|
project: data.id,
|
||||||
|
payload: { type: Event.Updated.type, properties: data },
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
|
||||||
Effect.sync(() => Database.use(fn))
|
|
||||||
|
|
||||||
const emitUpdated = (data: Info) =>
|
const resolveGitPath = (cwd: string, name: string) => {
|
||||||
Effect.sync(() =>
|
if (!name) return cwd
|
||||||
GlobalBus.emit("event", {
|
name = name.replace(/[\r\n]+$/, "")
|
||||||
directory: "global",
|
if (!name) return cwd
|
||||||
project: data.id,
|
name = AppFileSystem.windowsPath(name)
|
||||||
payload: { type: Event.Updated.type, properties: data },
|
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||||
}),
|
return pathSvc.resolve(cwd, name)
|
||||||
)
|
}
|
||||||
|
|
||||||
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const resolveGitPath = (cwd: string, name: string) => {
|
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||||
if (!name) return cwd
|
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||||
name = name.replace(/[\r\n]+$/, "")
|
Effect.map((x) => x.trim()),
|
||||||
if (!name) return cwd
|
Effect.map(ProjectID.make),
|
||||||
name = AppFileSystem.windowsPath(name)
|
Effect.catch(() => Effect.void),
|
||||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
)
|
||||||
return pathSvc.resolve(cwd, name)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const scope = yield* Scope.Scope
|
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||||
|
log.info("fromDirectory", { directory })
|
||||||
|
|
||||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
// Phase 1: discover git info
|
||||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||||
Effect.map((x) => x.trim()),
|
|
||||||
Effect.map(ProjectID.make),
|
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||||
Effect.catch(() => Effect.void),
|
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||||
)
|
const dotgit = dotgitMatches[0]
|
||||||
|
|
||||||
|
if (!dotgit) {
|
||||||
|
return {
|
||||||
|
id: ProjectID.global,
|
||||||
|
worktree: "/",
|
||||||
|
sandbox: "/",
|
||||||
|
vcs: fakeVcs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sandbox = pathSvc.dirname(dotgit)
|
||||||
|
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||||
|
let id = yield* readCachedProjectId(dotgit)
|
||||||
|
|
||||||
|
if (!gitBinary) {
|
||||||
|
return {
|
||||||
|
id: id ?? ProjectID.global,
|
||||||
|
worktree: sandbox,
|
||||||
|
sandbox,
|
||||||
|
vcs: fakeVcs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||||
|
if (commonDir.code !== 0) {
|
||||||
|
return {
|
||||||
|
id: id ?? ProjectID.global,
|
||||||
|
worktree: sandbox,
|
||||||
|
sandbox,
|
||||||
|
vcs: fakeVcs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const worktree = (() => {
|
||||||
|
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
||||||
|
return common === sandbox ? sandbox : pathSvc.dirname(common)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (id == null) {
|
||||||
|
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||||
|
const roots = revList.text
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.toSorted()
|
||||||
|
|
||||||
|
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||||
|
if (id) {
|
||||||
|
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||||
|
if (topLevel.code !== 0) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
worktree: sandbox,
|
||||||
|
sandbox,
|
||||||
|
vcs: fakeVcs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||||
|
|
||||||
|
return { id, sandbox, worktree, vcs: "git" as const }
|
||||||
})
|
})
|
||||||
|
|
||||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
// Phase 2: upsert
|
||||||
log.info("fromDirectory", { directory })
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||||
|
const existing = row
|
||||||
// Phase 1: discover git info
|
? fromRow(row)
|
||||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
: {
|
||||||
|
id: data.id,
|
||||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
worktree: data.worktree,
|
||||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
vcs: data.vcs,
|
||||||
const dotgit = dotgitMatches[0]
|
sandboxes: [] as string[],
|
||||||
|
time: { created: Date.now(), updated: Date.now() },
|
||||||
if (!dotgit) {
|
|
||||||
return {
|
|
||||||
id: ProjectID.global,
|
|
||||||
worktree: "/",
|
|
||||||
sandbox: "/",
|
|
||||||
vcs: fakeVcs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sandbox = pathSvc.dirname(dotgit)
|
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
|
||||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
let id = yield* readCachedProjectId(dotgit)
|
|
||||||
|
|
||||||
if (!gitBinary) {
|
const result: Info = {
|
||||||
return {
|
...existing,
|
||||||
id: id ?? ProjectID.global,
|
worktree: data.worktree,
|
||||||
worktree: sandbox,
|
vcs: data.vcs,
|
||||||
sandbox,
|
time: { ...existing.time, updated: Date.now() },
|
||||||
vcs: fakeVcs,
|
}
|
||||||
}
|
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||||
}
|
result.sandboxes.push(data.sandbox)
|
||||||
|
result.sandboxes = yield* Effect.forEach(
|
||||||
|
result.sandboxes,
|
||||||
|
(s) =>
|
||||||
|
fs.exists(s).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.map((exists) => (exists ? s : undefined)),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||||
|
|
||||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
yield* db((d) =>
|
||||||
if (commonDir.code !== 0) {
|
d
|
||||||
return {
|
.insert(ProjectTable)
|
||||||
id: id ?? ProjectID.global,
|
.values({
|
||||||
worktree: sandbox,
|
id: result.id,
|
||||||
sandbox,
|
worktree: result.worktree,
|
||||||
vcs: fakeVcs,
|
vcs: result.vcs ?? null,
|
||||||
}
|
name: result.name,
|
||||||
}
|
icon_url: result.icon?.url,
|
||||||
const worktree = (() => {
|
icon_color: result.icon?.color,
|
||||||
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
time_created: result.time.created,
|
||||||
return common === sandbox ? sandbox : pathSvc.dirname(common)
|
time_updated: result.time.updated,
|
||||||
})()
|
time_initialized: result.time.initialized,
|
||||||
|
sandboxes: result.sandboxes,
|
||||||
if (id == null) {
|
commands: result.commands,
|
||||||
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
|
})
|
||||||
}
|
.onConflictDoUpdate({
|
||||||
|
target: ProjectTable.id,
|
||||||
if (!id) {
|
set: {
|
||||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
|
||||||
const roots = revList.text
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.toSorted()
|
|
||||||
|
|
||||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
|
||||||
if (id) {
|
|
||||||
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
|
||||||
if (topLevel.code !== 0) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
worktree: sandbox,
|
|
||||||
sandbox,
|
|
||||||
vcs: fakeVcs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
|
||||||
|
|
||||||
return { id, sandbox, worktree, vcs: "git" as const }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Phase 2: upsert
|
|
||||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
|
||||||
const existing = row
|
|
||||||
? fromRow(row)
|
|
||||||
: {
|
|
||||||
id: data.id,
|
|
||||||
worktree: data.worktree,
|
|
||||||
vcs: data.vcs,
|
|
||||||
sandboxes: [] as string[],
|
|
||||||
time: { created: Date.now(), updated: Date.now() },
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
|
|
||||||
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
||||||
|
|
||||||
const result: Info = {
|
|
||||||
...existing,
|
|
||||||
worktree: data.worktree,
|
|
||||||
vcs: data.vcs,
|
|
||||||
time: { ...existing.time, updated: Date.now() },
|
|
||||||
}
|
|
||||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
|
||||||
result.sandboxes.push(data.sandbox)
|
|
||||||
result.sandboxes = yield* Effect.forEach(
|
|
||||||
result.sandboxes,
|
|
||||||
(s) =>
|
|
||||||
fs.exists(s).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((exists) => (exists ? s : undefined)),
|
|
||||||
),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
|
||||||
|
|
||||||
yield* db((d) =>
|
|
||||||
d
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({
|
|
||||||
id: result.id,
|
|
||||||
worktree: result.worktree,
|
worktree: result.worktree,
|
||||||
vcs: result.vcs ?? null,
|
vcs: result.vcs ?? null,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
icon_url: result.icon?.url,
|
icon_url: result.icon?.url,
|
||||||
icon_color: result.icon?.color,
|
icon_color: result.icon?.color,
|
||||||
time_created: result.time.created,
|
|
||||||
time_updated: result.time.updated,
|
time_updated: result.time.updated,
|
||||||
time_initialized: result.time.initialized,
|
time_initialized: result.time.initialized,
|
||||||
sandboxes: result.sandboxes,
|
sandboxes: result.sandboxes,
|
||||||
commands: result.commands,
|
commands: result.commands,
|
||||||
})
|
},
|
||||||
.onConflictDoUpdate({
|
})
|
||||||
target: ProjectTable.id,
|
.run(),
|
||||||
set: {
|
)
|
||||||
worktree: result.worktree,
|
|
||||||
vcs: result.vcs ?? null,
|
if (data.id !== ProjectID.global) {
|
||||||
name: result.name,
|
yield* db((d) =>
|
||||||
icon_url: result.icon?.url,
|
d
|
||||||
icon_color: result.icon?.color,
|
.update(SessionTable)
|
||||||
time_updated: result.time.updated,
|
.set({ project_id: data.id })
|
||||||
time_initialized: result.time.initialized,
|
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||||
sandboxes: result.sandboxes,
|
|
||||||
commands: result.commands,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.run(),
|
.run(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (data.id !== ProjectID.global) {
|
yield* emitUpdated(result)
|
||||||
yield* db((d) =>
|
return { project: result, sandbox: data.sandbox }
|
||||||
d
|
})
|
||||||
.update(SessionTable)
|
|
||||||
.set({ project_id: data.id })
|
|
||||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
|
||||||
.run(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* emitUpdated(result)
|
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||||
return { project: result, sandbox: data.sandbox }
|
if (input.vcs !== "git") return
|
||||||
})
|
if (input.icon?.override) return
|
||||||
|
if (input.icon?.url) return
|
||||||
|
|
||||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
const matches = yield* fs
|
||||||
if (input.vcs !== "git") return
|
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||||
if (input.icon?.override) return
|
cwd: input.worktree,
|
||||||
if (input.icon?.url) return
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||||
|
if (!shortest) return
|
||||||
|
|
||||||
const matches = yield* fs
|
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
const base64 = Buffer.from(buffer).toString("base64")
|
||||||
cwd: input.worktree,
|
const mime = AppFileSystem.mimeType(shortest)
|
||||||
absolute: true,
|
const url = `data:${mime};base64,${base64}`
|
||||||
include: "file",
|
yield* update({ projectID: input.id, icon: { url } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Project.list")(function* () {
|
||||||
|
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
||||||
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||||
|
return row ? fromRow(row) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||||
|
const result = yield* db((d) =>
|
||||||
|
d
|
||||||
|
.update(ProjectTable)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
icon_url: input.icon?.url,
|
||||||
|
icon_color: input.icon?.color,
|
||||||
|
commands: input.commands,
|
||||||
|
time_updated: Date.now(),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie)
|
.where(eq(ProjectTable.id, input.projectID))
|
||||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
.returning()
|
||||||
if (!shortest) return
|
.get(),
|
||||||
|
)
|
||||||
|
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
||||||
|
const data = fromRow(result)
|
||||||
|
yield* emitUpdated(data)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
||||||
const base64 = Buffer.from(buffer).toString("base64")
|
if (input.project.vcs === "git") return input.project
|
||||||
const mime = AppFileSystem.mimeType(shortest)
|
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
|
||||||
const url = `data:${mime};base64,${base64}`
|
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
|
||||||
yield* update({ projectID: input.id, icon: { url } })
|
if (result.code !== 0) {
|
||||||
})
|
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
|
||||||
|
}
|
||||||
|
const { project } = yield* fromDirectory(input.directory)
|
||||||
|
return project
|
||||||
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Project.list")(function* () {
|
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
|
||||||
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
yield* db((d) =>
|
||||||
})
|
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
||||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||||
return row ? fromRow(row) : undefined
|
if (!row) return []
|
||||||
})
|
const data = fromRow(row)
|
||||||
|
return yield* Effect.forEach(
|
||||||
|
data.sandboxes,
|
||||||
|
(dir) =>
|
||||||
|
fs.isDir(dir).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.map((ok) => (ok ? dir : undefined)),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
|
||||||
const result = yield* db((d) =>
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||||
d
|
if (!row) throw new Error(`Project not found: ${id}`)
|
||||||
.update(ProjectTable)
|
const sboxes = [...row.sandboxes]
|
||||||
.set({
|
if (!sboxes.includes(directory)) sboxes.push(directory)
|
||||||
name: input.name,
|
const result = yield* db((d) =>
|
||||||
icon_url: input.icon?.url,
|
d
|
||||||
icon_color: input.icon?.color,
|
.update(ProjectTable)
|
||||||
commands: input.commands,
|
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||||
time_updated: Date.now(),
|
.where(eq(ProjectTable.id, id))
|
||||||
})
|
.returning()
|
||||||
.where(eq(ProjectTable.id, input.projectID))
|
.get(),
|
||||||
.returning()
|
)
|
||||||
.get(),
|
if (!result) throw new Error(`Project not found: ${id}`)
|
||||||
)
|
yield* emitUpdated(fromRow(result))
|
||||||
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
})
|
||||||
const data = fromRow(result)
|
|
||||||
yield* emitUpdated(data)
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
|
|
||||||
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
|
||||||
if (input.project.vcs === "git") return input.project
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||||
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
|
if (!row) throw new Error(`Project not found: ${id}`)
|
||||||
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
|
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
||||||
if (result.code !== 0) {
|
const result = yield* db((d) =>
|
||||||
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
|
d
|
||||||
}
|
.update(ProjectTable)
|
||||||
const { project } = yield* fromDirectory(input.directory)
|
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||||
return project
|
.where(eq(ProjectTable.id, id))
|
||||||
})
|
.returning()
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
if (!result) throw new Error(`Project not found: ${id}`)
|
||||||
|
yield* emitUpdated(fromRow(result))
|
||||||
|
})
|
||||||
|
|
||||||
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
|
return Service.of({
|
||||||
yield* db((d) =>
|
fromDirectory,
|
||||||
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
discover,
|
||||||
)
|
list,
|
||||||
})
|
get,
|
||||||
|
update,
|
||||||
|
initGit,
|
||||||
|
setInitialized,
|
||||||
|
sandboxes,
|
||||||
|
addSandbox,
|
||||||
|
removeSandbox,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
export const defaultLayer = layer.pipe(
|
||||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
if (!row) return []
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
const data = fromRow(row)
|
Layer.provide(NodePath.layer),
|
||||||
return yield* Effect.forEach(
|
)
|
||||||
data.sandboxes,
|
|
||||||
(dir) =>
|
|
||||||
fs.isDir(dir).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((ok) => (ok ? dir : undefined)),
|
|
||||||
),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
|
export function list() {
|
||||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
return Database.use((db) =>
|
||||||
if (!row) throw new Error(`Project not found: ${id}`)
|
db
|
||||||
const sboxes = [...row.sandboxes]
|
.select()
|
||||||
if (!sboxes.includes(directory)) sboxes.push(directory)
|
.from(ProjectTable)
|
||||||
const result = yield* db((d) =>
|
.all()
|
||||||
d
|
.map((row) => fromRow(row)),
|
||||||
.update(ProjectTable)
|
)
|
||||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
}
|
||||||
.where(eq(ProjectTable.id, id))
|
|
||||||
.returning()
|
export function get(id: ProjectID): Info | undefined {
|
||||||
.get(),
|
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||||
)
|
if (!row) return undefined
|
||||||
if (!result) throw new Error(`Project not found: ${id}`)
|
return fromRow(row)
|
||||||
yield* emitUpdated(fromRow(result))
|
}
|
||||||
})
|
|
||||||
|
export function setInitialized(id: ProjectID) {
|
||||||
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
|
Database.use((db) =>
|
||||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||||
if (!row) throw new Error(`Project not found: ${id}`)
|
|
||||||
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
|
||||||
const result = yield* db((d) =>
|
|
||||||
d
|
|
||||||
.update(ProjectTable)
|
|
||||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
|
||||||
.where(eq(ProjectTable.id, id))
|
|
||||||
.returning()
|
|
||||||
.get(),
|
|
||||||
)
|
|
||||||
if (!result) throw new Error(`Project not found: ${id}`)
|
|
||||||
yield* emitUpdated(fromRow(result))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
fromDirectory,
|
|
||||||
discover,
|
|
||||||
list,
|
|
||||||
get,
|
|
||||||
update,
|
|
||||||
initGit,
|
|
||||||
setInitialized,
|
|
||||||
sandboxes,
|
|
||||||
addSandbox,
|
|
||||||
removeSandbox,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function list() {
|
|
||||||
return Database.use((db) =>
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(ProjectTable)
|
|
||||||
.all()
|
|
||||||
.map((row) => fromRow(row)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function get(id: ProjectID): Info | undefined {
|
|
||||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
|
||||||
if (!row) return undefined
|
|
||||||
return fromRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setInitialized(id: ProjectID) {
|
|
||||||
Database.use((db) =>
|
|
||||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,223 +11,221 @@ import { Log } from "@/util/log"
|
|||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export namespace Vcs {
|
const log = Log.create({ service: "vcs" })
|
||||||
const log = Log.create({ service: "vcs" })
|
|
||||||
|
|
||||||
const count = (text: string) => {
|
const count = (text: string) => {
|
||||||
if (!text) return 0
|
if (!text) return 0
|
||||||
if (!text.endsWith("\n")) return text.split("\n").length
|
if (!text.endsWith("\n")) return text.split("\n").length
|
||||||
return text.slice(0, -1).split("\n").length
|
return text.slice(0, -1).split("\n").length
|
||||||
}
|
|
||||||
|
|
||||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
|
||||||
const full = path.join(cwd, file)
|
|
||||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
|
||||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
|
||||||
if (Buffer.from(buf).includes(0)) return ""
|
|
||||||
return Buffer.from(buf).toString("utf8")
|
|
||||||
})
|
|
||||||
|
|
||||||
const nums = (list: Git.Stat[]) =>
|
|
||||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
|
||||||
|
|
||||||
const merge = (...lists: Git.Item[][]) => {
|
|
||||||
const out = new Map<string, Git.Item>()
|
|
||||||
lists.flat().forEach((item) => {
|
|
||||||
if (!out.has(item.file)) out.set(item.file, item)
|
|
||||||
})
|
|
||||||
return [...out.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Effect.fnUntraced(function* (
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
cwd: string,
|
|
||||||
ref: string | undefined,
|
|
||||||
list: Git.Item[],
|
|
||||||
map: Map<string, { additions: number; deletions: number }>,
|
|
||||||
) {
|
|
||||||
const base = ref ? yield* git.prefix(cwd) : ""
|
|
||||||
const patch = (file: string, before: string, after: string) =>
|
|
||||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
|
||||||
const next = yield* Effect.forEach(
|
|
||||||
list,
|
|
||||||
(item) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
|
||||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
|
||||||
const stat = map.get(item.file)
|
|
||||||
return {
|
|
||||||
file: item.file,
|
|
||||||
patch: patch(item.file, before, after),
|
|
||||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
|
||||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
|
||||||
status: item.status,
|
|
||||||
} satisfies FileDiff
|
|
||||||
}),
|
|
||||||
{ concurrency: 8 },
|
|
||||||
)
|
|
||||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const track = Effect.fnUntraced(function* (
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
cwd: string,
|
|
||||||
ref: string | undefined,
|
|
||||||
) {
|
|
||||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
|
||||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
|
||||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
|
||||||
})
|
|
||||||
|
|
||||||
const compare = Effect.fnUntraced(function* (
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
cwd: string,
|
|
||||||
ref: string,
|
|
||||||
) {
|
|
||||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
|
||||||
concurrency: 3,
|
|
||||||
})
|
|
||||||
return yield* files(
|
|
||||||
fs,
|
|
||||||
git,
|
|
||||||
cwd,
|
|
||||||
ref,
|
|
||||||
merge(
|
|
||||||
list,
|
|
||||||
extra.filter((item) => item.code === "??"),
|
|
||||||
),
|
|
||||||
nums(stats),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Mode = z.enum(["git", "branch"])
|
|
||||||
export type Mode = z.infer<typeof Mode>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
BranchUpdated: BusEvent.define(
|
|
||||||
"vcs.branch.updated",
|
|
||||||
z.object({
|
|
||||||
branch: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
branch: z.string().optional(),
|
|
||||||
default_branch: z.string().optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "VcsInfo",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const FileDiff = z
|
|
||||||
.object({
|
|
||||||
file: z.string(),
|
|
||||||
patch: z.string(),
|
|
||||||
additions: z.number(),
|
|
||||||
deletions: z.number(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "VcsFileDiff",
|
|
||||||
})
|
|
||||||
export type FileDiff = z.infer<typeof FileDiff>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
|
||||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
|
||||||
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
current: string | undefined
|
|
||||||
root: Git.Base | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const git = yield* Git.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Vcs.state")(function* (ctx) {
|
|
||||||
if (ctx.project.vcs !== "git") {
|
|
||||||
return { current: undefined, root: undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
const get = Effect.fnUntraced(function* () {
|
|
||||||
return yield* git.branch(ctx.directory)
|
|
||||||
})
|
|
||||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
|
||||||
concurrency: 2,
|
|
||||||
})
|
|
||||||
const value = { current, root }
|
|
||||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
|
||||||
|
|
||||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
|
||||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
|
||||||
Stream.runForEach((_evt) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const next = yield* get()
|
|
||||||
if (next !== value.current) {
|
|
||||||
log.info("branch changed", { from: value.current, to: next })
|
|
||||||
value.current = next
|
|
||||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
init: Effect.fn("Vcs.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
branch: Effect.fn("Vcs.branch")(function* () {
|
|
||||||
return yield* InstanceState.use(state, (x) => x.current)
|
|
||||||
}),
|
|
||||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
|
||||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
|
||||||
}),
|
|
||||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
|
||||||
const value = yield* InstanceState.get(state)
|
|
||||||
if (Instance.project.vcs !== "git") return []
|
|
||||||
if (mode === "git") {
|
|
||||||
return yield* track(
|
|
||||||
fs,
|
|
||||||
git,
|
|
||||||
Instance.directory,
|
|
||||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value.root) return []
|
|
||||||
if (value.current && value.current === value.root.name) return []
|
|
||||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
|
||||||
if (!ref) return []
|
|
||||||
return yield* compare(fs, git, Instance.directory, ref)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||||
|
const full = path.join(cwd, file)
|
||||||
|
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||||
|
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
|
if (Buffer.from(buf).includes(0)) return ""
|
||||||
|
return Buffer.from(buf).toString("utf8")
|
||||||
|
})
|
||||||
|
|
||||||
|
const nums = (list: Git.Stat[]) =>
|
||||||
|
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||||
|
|
||||||
|
const merge = (...lists: Git.Item[][]) => {
|
||||||
|
const out = new Map<string, Git.Item>()
|
||||||
|
lists.flat().forEach((item) => {
|
||||||
|
if (!out.has(item.file)) out.set(item.file, item)
|
||||||
|
})
|
||||||
|
return [...out.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Effect.fnUntraced(function* (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
git: Git.Interface,
|
||||||
|
cwd: string,
|
||||||
|
ref: string | undefined,
|
||||||
|
list: Git.Item[],
|
||||||
|
map: Map<string, { additions: number; deletions: number }>,
|
||||||
|
) {
|
||||||
|
const base = ref ? yield* git.prefix(cwd) : ""
|
||||||
|
const patch = (file: string, before: string, after: string) =>
|
||||||
|
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||||
|
const next = yield* Effect.forEach(
|
||||||
|
list,
|
||||||
|
(item) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||||
|
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||||
|
const stat = map.get(item.file)
|
||||||
|
return {
|
||||||
|
file: item.file,
|
||||||
|
patch: patch(item.file, before, after),
|
||||||
|
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||||
|
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||||
|
status: item.status,
|
||||||
|
} satisfies FileDiff
|
||||||
|
}),
|
||||||
|
{ concurrency: 8 },
|
||||||
|
)
|
||||||
|
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||||
|
})
|
||||||
|
|
||||||
|
const track = Effect.fnUntraced(function* (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
git: Git.Interface,
|
||||||
|
cwd: string,
|
||||||
|
ref: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||||
|
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||||
|
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||||
|
})
|
||||||
|
|
||||||
|
const compare = Effect.fnUntraced(function* (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
git: Git.Interface,
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
) {
|
||||||
|
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||||
|
concurrency: 3,
|
||||||
|
})
|
||||||
|
return yield* files(
|
||||||
|
fs,
|
||||||
|
git,
|
||||||
|
cwd,
|
||||||
|
ref,
|
||||||
|
merge(
|
||||||
|
list,
|
||||||
|
extra.filter((item) => item.code === "??"),
|
||||||
|
),
|
||||||
|
nums(stats),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Mode = z.enum(["git", "branch"])
|
||||||
|
export type Mode = z.infer<typeof Mode>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
BranchUpdated: BusEvent.define(
|
||||||
|
"vcs.branch.updated",
|
||||||
|
z.object({
|
||||||
|
branch: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
branch: z.string().optional(),
|
||||||
|
default_branch: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "VcsInfo",
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const FileDiff = z
|
||||||
|
.object({
|
||||||
|
file: z.string(),
|
||||||
|
patch: z.string(),
|
||||||
|
additions: z.number(),
|
||||||
|
deletions: z.number(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "VcsFileDiff",
|
||||||
|
})
|
||||||
|
export type FileDiff = z.infer<typeof FileDiff>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
|
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||||
|
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
current: string | undefined
|
||||||
|
root: Git.Base | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Vcs.state")(function* (ctx) {
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
|
return { current: undefined, root: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = Effect.fnUntraced(function* () {
|
||||||
|
return yield* git.branch(ctx.directory)
|
||||||
|
})
|
||||||
|
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||||
|
concurrency: 2,
|
||||||
|
})
|
||||||
|
const value = { current, root }
|
||||||
|
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||||
|
|
||||||
|
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||||
|
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||||
|
Stream.runForEach((_evt) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const next = yield* get()
|
||||||
|
if (next !== value.current) {
|
||||||
|
log.info("branch changed", { from: value.current, to: next })
|
||||||
|
value.current = next
|
||||||
|
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
init: Effect.fn("Vcs.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
branch: Effect.fn("Vcs.branch")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (x) => x.current)
|
||||||
|
}),
|
||||||
|
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||||
|
}),
|
||||||
|
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||||
|
const value = yield* InstanceState.get(state)
|
||||||
|
if (Instance.project.vcs !== "git") return []
|
||||||
|
if (mode === "git") {
|
||||||
|
return yield* track(
|
||||||
|
fs,
|
||||||
|
git,
|
||||||
|
Instance.directory,
|
||||||
|
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.root) return []
|
||||||
|
if (value.current && value.current === value.root.name) return []
|
||||||
|
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||||
|
if (!ref) return []
|
||||||
|
return yield* compare(fs, git, Instance.directory, ref)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ProviderID, ModelID } from "../../provider/schema"
|
|||||||
import { ToolRegistry } from "../../tool/registry"
|
import { ToolRegistry } from "../../tool/registry"
|
||||||
import { Worktree } from "../../worktree"
|
import { Worktree } from "../../worktree"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Project } from "../../project/project"
|
import { Project } from "../../project"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
import { Session } from "../../session"
|
import { Session } from "../../session"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import z from "zod"
|
|||||||
import { Format } from "../../format"
|
import { Format } from "../../format"
|
||||||
import { TuiRoutes } from "./tui"
|
import { TuiRoutes } from "./tui"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Vcs } from "../../project/vcs"
|
import { Vcs } from "../../project"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Skill } from "../../skill"
|
import { Skill } from "../../skill"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from "hono"
|
|||||||
import { describeRoute, validator } from "hono-openapi"
|
import { describeRoute, validator } from "hono-openapi"
|
||||||
import { resolver } from "hono-openapi"
|
import { resolver } from "hono-openapi"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Project } from "../../project/project"
|
import { Project } from "../../project"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ProjectID } from "../../project/schema"
|
import { ProjectID } from "../../project/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
import { Project } from "../project/project"
|
import { Project } from "../project"
|
||||||
import { Database, eq } from "../storage/db"
|
import { Database, eq } from "../storage/db"
|
||||||
import { ProjectTable } from "../project/project.sql"
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import type { ProjectID } from "../project/schema"
|
import type { ProjectID } from "../project/schema"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project"
|
||||||
import { Database, eq } from "../../src/storage/db"
|
import { Database, eq } from "../../src/storage/db"
|
||||||
import { SessionTable } from "../../src/session/session.sql"
|
import { SessionTable } from "../../src/session/session.sql"
|
||||||
import { ProjectTable } from "../../src/project/project.sql"
|
import { ProjectTable } from "../../src/project/project.sql"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project"
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AppRuntime } from "../../src/effect/app-runtime"
|
|||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
import { Vcs } from "../../src/project/vcs"
|
import { Vcs } from "../../src/project"
|
||||||
|
|
||||||
// Skip in CI — native @parcel/watcher binding needed
|
// Skip in CI — native @parcel/watcher binding needed
|
||||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project"
|
||||||
import { Session as SessionNs } from "../../src/session"
|
import { Session as SessionNs } from "../../src/session"
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|||||||
Reference in New Issue
Block a user