Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db0207d01e | |||
| 93141d1554 | |||
| 46e77b0730 | |||
| baf0acd7b5 | |||
| 9afa0b5f8a | |||
| 19d60d0bf5 |
@@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
|
||||
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
|
||||
|
||||
@@ -84,15 +84,20 @@ export const layer = Layer.effect(
|
||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||
}
|
||||
|
||||
const patch =
|
||||
input.moveChanges && source.directory !== destination.directory
|
||||
? yield* git
|
||||
.patch(current.location.directory)
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: ""
|
||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||
if (moveChanges && !sourceRepository)
|
||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
||||
const patch = sourceRepository
|
||||
? yield* git.change
|
||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: Git.ChangeSet.make("")
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
||||
yield* git
|
||||
.applyPatch({ directory, patch })
|
||||
.change.apply({ repository, path: directory, changes: patch })
|
||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||
}
|
||||
|
||||
@@ -104,7 +109,20 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
yield* git.softResetChanges(current.location.directory).pipe(
|
||||
const repository = yield* git.repo.discover(current.location.directory)
|
||||
if (!repository)
|
||||
return yield* new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: "Source is not a Git repository",
|
||||
})
|
||||
yield* git.change
|
||||
.discard({
|
||||
repository,
|
||||
path: current.location.directory,
|
||||
index: "preserve",
|
||||
untracked: "remove",
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ResetSourceChangesError({
|
||||
@@ -113,7 +131,7 @@ export const layer = Layer.effect(
|
||||
cause: error.cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export * as File from "./file"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, RelativePath } from "./schema"
|
||||
|
||||
export const Diff = Schema.Struct({
|
||||
path: RelativePath,
|
||||
status: Schema.Literals(["added", "modified", "deleted"]),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
patch: Schema.String,
|
||||
}).annotate({ identifier: "File.Diff" })
|
||||
export type Diff = typeof Diff.Type
|
||||
@@ -125,4 +125,4 @@ const baseLayer = Layer.effect(
|
||||
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = baseLayer.pipe(Layer.provideMerge(FileSystemSearch.defaultLayer))
|
||||
|
||||
@@ -119,7 +119,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = yield* git.dir(location.directory)
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
@@ -139,4 +139,4 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
+687
-200
@@ -1,31 +1,50 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { AppProcess } from "./process"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { File } from "./file"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
export interface Repo {
|
||||
/**
|
||||
* The root directory of the working tree that contains the input path.
|
||||
*
|
||||
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
|
||||
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
|
||||
* `/home/me/app-feature`.
|
||||
*/
|
||||
readonly directory: AbsolutePath
|
||||
/**
|
||||
* The shared Git storage directory used by this repo and any linked worktrees.
|
||||
*
|
||||
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
* For a linked worktree at `/home/me/app-feature` whose main checkout is
|
||||
* `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
*/
|
||||
readonly store: AbsolutePath
|
||||
}
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
gitDirectory: AbsolutePath,
|
||||
commonDirectory: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
||||
export type ChangeSet = typeof ChangeSet.Type
|
||||
|
||||
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
||||
export type TreeID = typeof TreeID.Type
|
||||
|
||||
export class OperationError extends Schema.TaggedErrorClass<OperationError>()("Git.OperationError", {
|
||||
operation: Schema.Literals([
|
||||
"clone",
|
||||
"fetch",
|
||||
"checkout",
|
||||
"reset",
|
||||
"create",
|
||||
"refresh",
|
||||
"write_tree",
|
||||
"list_files",
|
||||
"diff",
|
||||
"restore",
|
||||
]),
|
||||
message: Schema.String,
|
||||
directory: Schema.optional(AbsolutePath),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class Worktree extends Schema.Class<Worktree>("Git.Worktree")({
|
||||
directory: AbsolutePath,
|
||||
kind: Schema.Literals(["main", "linked"]),
|
||||
}) {}
|
||||
|
||||
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
|
||||
operation: Schema.Literals(["create", "remove", "list"]),
|
||||
@@ -43,35 +62,107 @@ export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.Patch
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
||||
readonly origin: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly head: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly dir: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly branch: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly clone: (input: {
|
||||
remote: string
|
||||
target: string
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
|
||||
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
|
||||
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: {
|
||||
repo: Repo
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
readonly repo: {
|
||||
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
||||
readonly clone: (input: {
|
||||
remote: string
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) => Effect.Effect<Repository, OperationError>
|
||||
readonly create: (input: {
|
||||
worktree: AbsolutePath
|
||||
gitDirectory: AbsolutePath
|
||||
seed?: Repository
|
||||
}) => Effect.Effect<Repository, OperationError>
|
||||
}
|
||||
readonly remote: {
|
||||
readonly get: (repository: Repository, name?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
readonly history: {
|
||||
readonly head: (repository: Repository) => Effect.Effect<string | undefined>
|
||||
readonly branch: (repository: Repository) => Effect.Effect<string | undefined>
|
||||
readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect<string | undefined>
|
||||
readonly rootCommits: (repository: Repository) => Effect.Effect<readonly string[]>
|
||||
}
|
||||
readonly sync: {
|
||||
readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect<void, OperationError>
|
||||
readonly fetchBranch: (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; force?: boolean },
|
||||
) => Effect.Effect<void, OperationError>
|
||||
readonly checkoutRemoteBranch: (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; reset?: boolean },
|
||||
) => Effect.Effect<void, OperationError>
|
||||
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
readonly change: {
|
||||
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
|
||||
readonly apply: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
readonly discard: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
}
|
||||
readonly worktree: {
|
||||
readonly create: (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Repository, WorktreeError>
|
||||
readonly remove: (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, WorktreeError>
|
||||
readonly list: (repository: Repository) => Effect.Effect<readonly Worktree[], WorktreeError>
|
||||
}
|
||||
readonly index: {
|
||||
/** Refresh only the requested project-relative scope, preserving all other entries. */
|
||||
readonly refresh: (input: {
|
||||
repository: Repository
|
||||
scope: RelativePath
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) => Effect.Effect<{ readonly skipped: readonly RelativePath[] }, OperationError>
|
||||
}
|
||||
readonly tree: {
|
||||
readonly capture: (input: {
|
||||
repository: Repository
|
||||
scopes: readonly RelativePath[]
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) => Effect.Effect<TreeID, OperationError>
|
||||
readonly write: (repository: Repository) => Effect.Effect<TreeID, OperationError>
|
||||
readonly files: (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
}) => Effect.Effect<readonly RelativePath[], OperationError>
|
||||
readonly diff: (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly preview: (input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly restore: (input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
}) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
||||
@@ -81,8 +172,11 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const locked = <A, E, R>(repository: Repository, effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(repository.gitDirectory)(effect)
|
||||
|
||||
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
||||
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
@@ -92,23 +186,25 @@ export const layer = Layer.effect(
|
||||
const cwd = path.dirname(dotgit)
|
||||
const git = run(cwd, proc)
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
|
||||
const gitDir = yield* git(["rev-parse", "--git-dir"])
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
|
||||
if (commonDir.exitCode !== 0) return undefined
|
||||
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined
|
||||
|
||||
return {
|
||||
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
} satisfies Repo
|
||||
return new Repository({
|
||||
worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)),
|
||||
commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
})
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
|
||||
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
|
||||
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
|
||||
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
|
||||
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
@@ -117,116 +213,515 @@ export const layer = Layer.effect(
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
const origin = Effect.fn("Git.origin")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"])
|
||||
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.head")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["rev-parse", "HEAD"])
|
||||
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const dir = Effect.fn("Git.dir")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
|
||||
const remoteHead = Effect.fn("Git.history.defaultRemoteBranch")(function* (
|
||||
repository: Repository,
|
||||
remoteName = "origin",
|
||||
) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return AbsolutePath.make(resolvePath(directory, result.text))
|
||||
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.branch")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
const operation = Effect.fnUntraced(function* (
|
||||
operation: OperationError["operation"],
|
||||
directory: AbsolutePath,
|
||||
args: string[],
|
||||
) {
|
||||
const result = yield* execute(directory, proc)(args).pipe(
|
||||
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new OperationError({
|
||||
operation,
|
||||
directory,
|
||||
message: result.stderr.trim() || result.text.trim() || `Git ${operation} failed`,
|
||||
})
|
||||
})
|
||||
|
||||
const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(/^refs\/remotes\//, "") || undefined
|
||||
})
|
||||
|
||||
const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) =>
|
||||
execute(
|
||||
path.dirname(input.target),
|
||||
proc,
|
||||
)([
|
||||
const clone = Effect.fn("Git.repo.clone")(function* (input: {
|
||||
remote: string
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) {
|
||||
yield* operation("clone", AbsolutePath.make(path.dirname(input.directory)), [
|
||||
"clone",
|
||||
"--depth",
|
||||
String(input.depth ?? 100),
|
||||
...(input.branch ? ["--branch", input.branch] : []),
|
||||
"--",
|
||||
input.remote,
|
||||
input.target,
|
||||
]),
|
||||
)
|
||||
input.directory,
|
||||
])
|
||||
const repository = yield* discover(input.directory)
|
||||
if (repository) return repository
|
||||
return yield* new OperationError({
|
||||
operation: "clone",
|
||||
directory: input.directory,
|
||||
message: "Cloned repository could not be opened",
|
||||
})
|
||||
})
|
||||
|
||||
const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"]))
|
||||
const fetch = Effect.fn("Git.sync.fetchRemotes")(function* (
|
||||
repository: Repository,
|
||||
input: { prune?: boolean } = {},
|
||||
) {
|
||||
yield* operation("fetch", repository.worktree, ["fetch", "--all", ...(input.prune === false ? [] : ["--prune"])])
|
||||
})
|
||||
|
||||
const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) =>
|
||||
execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]),
|
||||
)
|
||||
const fetchBranch = Effect.fn("Git.sync.fetchBranch")(function* (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; force?: boolean },
|
||||
) {
|
||||
const remoteName = input.remote ?? "origin"
|
||||
const spec = `refs/heads/${input.branch}:refs/remotes/${remoteName}/${input.branch}`
|
||||
yield* operation("fetch", repository.worktree, ["fetch", remoteName, input.force === false ? spec : `+${spec}`])
|
||||
})
|
||||
|
||||
const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
|
||||
execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
|
||||
)
|
||||
const checkout = Effect.fn("Git.sync.checkoutRemoteBranch")(function* (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; reset?: boolean },
|
||||
) {
|
||||
const remoteName = input.remote ?? "origin"
|
||||
yield* operation("checkout", repository.worktree, [
|
||||
"checkout",
|
||||
...(input.reset === false ? [input.branch] : ["-B", input.branch, `${remoteName}/${input.branch}`]),
|
||||
])
|
||||
})
|
||||
|
||||
const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
|
||||
execute(directory, proc)(["reset", "--hard", target]),
|
||||
)
|
||||
const reset = Effect.fn("Git.sync.resetHard")(function* (
|
||||
repository: Repository,
|
||||
revision: string,
|
||||
) {
|
||||
yield* operation("reset", repository.worktree, ["reset", "--hard", revision])
|
||||
})
|
||||
|
||||
const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
|
||||
const root = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["rev-parse", "--show-toplevel"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
const repositoryArgs = (repository: Repository, args: string[]) => [
|
||||
"--git-dir",
|
||||
repository.gitDirectory,
|
||||
"--work-tree",
|
||||
repository.worktree,
|
||||
...args,
|
||||
]
|
||||
|
||||
const repositoryOperation = Effect.fnUntraced(function* (
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(repository, args), {
|
||||
cwd: repository.worktree,
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
message: cause.message,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || text.trim() || `Git ${operationName} failed`,
|
||||
})
|
||||
})
|
||||
|
||||
const create = Effect.fn("Git.repo.create")(function* (input: {
|
||||
worktree: AbsolutePath
|
||||
gitDirectory: AbsolutePath
|
||||
seed?: Repository
|
||||
}) {
|
||||
yield* fs.ensureDir(input.gitDirectory).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: input.gitDirectory,
|
||||
message: "Failed to create Git storage",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (root.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
|
||||
})
|
||||
}
|
||||
const repo = AbsolutePath.make(resolvePath(directory, root.text))
|
||||
const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
|
||||
const repository = new Repository({
|
||||
worktree: input.worktree,
|
||||
gitDirectory: input.gitDirectory,
|
||||
commonDirectory: input.gitDirectory,
|
||||
})
|
||||
yield* repositoryOperation("create", repository, ["init"])
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["core.autocrlf", "false"],
|
||||
["core.longpaths", "true"],
|
||||
["core.symlinks", "true"],
|
||||
["core.fsmonitor", "false"],
|
||||
["feature.manyFiles", "true"],
|
||||
["index.version", "4"],
|
||||
["index.threads", "true"],
|
||||
["core.untrackedCache", "true"],
|
||||
],
|
||||
([key, value]) => repositoryOperation("create", repository, ["config", key, value]),
|
||||
{ discard: true },
|
||||
)
|
||||
if (!input.seed) return repository
|
||||
yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: input.gitDirectory,
|
||||
message: "Failed to configure shared Git objects",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* fs.writeFileString(
|
||||
path.join(input.gitDirectory, "objects", "info", "alternates"),
|
||||
path.join(input.seed.commonDirectory, "objects") + "\n",
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: input.gitDirectory,
|
||||
message: "Failed to configure shared Git objects",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* fs
|
||||
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
return repository
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("Git.index.refresh")(function* (input: {
|
||||
repository: Repository
|
||||
scope: RelativePath
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
list(["ls-files", "--others", "--exclude-standard", "-z", "--", input.scope]),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const candidates = Array.from(new Set([...tracked, ...untracked]))
|
||||
if (!candidates.length) return { skipped: [] }
|
||||
const ignored = input.ignores
|
||||
? new Set(
|
||||
(
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
input.ignores,
|
||||
["check-ignore", "--no-index", "--stdin", "-z"],
|
||||
{ stdin: candidates.join("\0") + "\0" },
|
||||
).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))
|
||||
).text
|
||||
.split("\0")
|
||||
.filter(Boolean),
|
||||
)
|
||||
: new Set<string>()
|
||||
const allowed = candidates.filter((item) => !ignored.has(item))
|
||||
const maximum = input.maximumUntrackedFileBytes
|
||||
const skipped = maximum
|
||||
? (
|
||||
yield* Effect.forEach(
|
||||
untracked.filter((item) => allowed.includes(item)),
|
||||
(item) =>
|
||||
fs.stat(path.join(input.repository.worktree, item)).pipe(
|
||||
Effect.map((info) =>
|
||||
info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined,
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
).filter((item): item is RelativePath => item !== undefined)
|
||||
: []
|
||||
const stage = allowed.filter((item) => !skipped.includes(RelativePath.make(item)))
|
||||
const remove = [...ignored, ...skipped]
|
||||
if (remove.length)
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
input.repository,
|
||||
["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"],
|
||||
{ stdin: remove.join("\0") + "\0" },
|
||||
)
|
||||
if (stage.length)
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
input.repository,
|
||||
["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"],
|
||||
{ stdin: stage.join("\0") + "\0" },
|
||||
)
|
||||
return { skipped }
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
|
||||
})
|
||||
|
||||
const captureTree = Effect.fn("Git.tree.capture")((input: {
|
||||
repository: Repository
|
||||
scopes: readonly RelativePath[]
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
input.scopes,
|
||||
(scope) => refresh({ ...input, scope }),
|
||||
{ discard: true },
|
||||
)
|
||||
return yield* writeTree(input.repository)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const treeFiles = Effect.fn("Git.tree.files")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
}) {
|
||||
return (yield* repositoryOperation("list_files", input.repository, ["diff", "--name-only", "-z", input.from, input.to]))
|
||||
.text.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (
|
||||
yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (
|
||||
yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (
|
||||
yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
).text
|
||||
return {
|
||||
path: file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const entry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
const text = (
|
||||
yield* repositoryOperation("restore", repository, ["ls-tree", "-z", tree, "--", file])
|
||||
).text.replace(/\0$/, "")
|
||||
if (!text) return
|
||||
const match = text.match(/^(\d+)\s+\w+\s+([0-9a-f]+)\t/)
|
||||
if (!match) return yield* new OperationError({
|
||||
operation: "restore",
|
||||
directory: repository.worktree,
|
||||
message: `Invalid tree entry for ${file}`,
|
||||
})
|
||||
return { mode: match[1], object: match[2] }
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Git.tree.preview")((input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
|
||||
const env = { GIT_INDEX_FILE: index }
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
|
||||
yield* Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* entry(input.repository, tree, file)
|
||||
if (!source) {
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--force-remove", "--", file],
|
||||
{ env },
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
|
||||
{ env },
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const target = TreeID.make(
|
||||
(yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
|
||||
)
|
||||
return yield* treeDiff({
|
||||
repository: input.repository,
|
||||
from: input.current,
|
||||
to: target,
|
||||
context: input.context,
|
||||
paths: Array.from(input.files.keys()),
|
||||
})
|
||||
}).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const restore = Effect.fn("Git.tree.restore")((input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
if (yield* entry(input.repository, tree, file)) {
|
||||
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.remove(path.join(input.repository.worktree, file), { recursive: true, force: true })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "restore",
|
||||
directory: input.repository.worktree,
|
||||
message: `Failed to remove ${file}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const capture = Effect.fn("Git.change.capture")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
}) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const tracked = yield* execute(
|
||||
repo,
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (tracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = yield* execute(
|
||||
repo,
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (untracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
||||
execute(
|
||||
repo,
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
Effect.flatMap((result) =>
|
||||
// git diff --no-index returns 1 when differences were found.
|
||||
@@ -235,7 +730,7 @@ export const layer = Layer.effect(
|
||||
: Effect.fail(
|
||||
new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
||||
}),
|
||||
@@ -243,95 +738,78 @@ export const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
)
|
||||
return [tracked.text, ...created].filter(Boolean).join("\n")
|
||||
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
|
||||
})
|
||||
|
||||
const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
|
||||
const apply = Effect.fn("Git.change.apply")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", ["apply", "-"], {
|
||||
cwd: input.directory,
|
||||
cwd: input.path,
|
||||
extendEnv: true,
|
||||
stdin: Stream.make(new TextEncoder().encode(input.patch)),
|
||||
stdin: Stream.make(new TextEncoder().encode(input.changes)),
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
|
||||
new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "apply",
|
||||
directory: input.directory,
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
||||
})
|
||||
})
|
||||
|
||||
const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
|
||||
const reset = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["reset", "--hard", "HEAD"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
const discard = Effect.fn("Git.change.discard")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const restore = yield* execute(input.repository.worktree, proc)(
|
||||
input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope],
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (reset.exitCode !== 0) {
|
||||
if (restore.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
|
||||
directory: input.path,
|
||||
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
const clean = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["clean", "-fd"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
if (input.untracked === "preserve") return
|
||||
const clean = yield* execute(input.repository.worktree, proc)(["clean", "-fd", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
|
||||
const checkout = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["checkout", "--", "."]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (checkout.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
const clean = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["clean", "-fd", "--", "."]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const worktree = Effect.fnUntraced(function* (
|
||||
const worktreeRun = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repo: Repo,
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
worktreeDirectory?: AbsolutePath,
|
||||
cwd = repo.directory,
|
||||
cwd = repository.worktree,
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
@@ -350,52 +828,61 @@ export const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
||||
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
|
||||
const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
yield* worktreeRun(
|
||||
"create",
|
||||
input.repository,
|
||||
["worktree", "add", "--detach", input.directory, "HEAD"],
|
||||
input.directory,
|
||||
)
|
||||
const repository = yield* discover(input.directory)
|
||||
if (repository) return repository
|
||||
return yield* new WorktreeError({
|
||||
operation: "create",
|
||||
directory: input.directory,
|
||||
message: "Created worktree could not be opened",
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
|
||||
repo: Repo
|
||||
const worktreeRemove = Effect.fn("Git.worktree.remove")(function* (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) {
|
||||
yield* worktree(
|
||||
yield* worktreeRun(
|
||||
"remove",
|
||||
input.repo,
|
||||
input.repository,
|
||||
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
|
||||
input.directory,
|
||||
input.repo.store,
|
||||
input.repository.commonDirectory,
|
||||
)
|
||||
})
|
||||
|
||||
const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
|
||||
return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
|
||||
const worktreeList = Effect.fn("Git.worktree.list")(function* (repository: Repository) {
|
||||
return (yield* worktreeRun("list", repository, ["worktree", "list", "--porcelain"]))
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
|
||||
.map(
|
||||
(line, index) =>
|
||||
new Worktree({
|
||||
directory: AbsolutePath.make(resolvePath(repository.worktree, line.slice("worktree ".length).trim())),
|
||||
kind: index === 0 ? "main" : "linked",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
find,
|
||||
remote,
|
||||
roots,
|
||||
origin,
|
||||
head,
|
||||
dir,
|
||||
branch,
|
||||
remoteHead,
|
||||
clone,
|
||||
fetch,
|
||||
fetchBranch,
|
||||
checkout,
|
||||
reset,
|
||||
patch,
|
||||
applyPatch,
|
||||
resetChanges,
|
||||
softResetChanges,
|
||||
worktreeCreate,
|
||||
worktreeRemove,
|
||||
worktreeList,
|
||||
repo: { discover, clone, create },
|
||||
remote: { get: remote },
|
||||
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
||||
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
||||
change: { capture, apply, discard },
|
||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||
index: { refresh },
|
||||
tree: { capture: captureTree, write: writeTree, files: treeFiles, diff: treeDiff, preview, restore },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -403,7 +890,7 @@ export const layer = Layer.effect(
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
|
||||
|
||||
export interface Result {
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
|
||||
@@ -46,6 +46,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
@@ -54,9 +55,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
|
||||
)
|
||||
const location = Location.layer(ref)
|
||||
const systemContext = SystemContextBuiltIns.locationLayer
|
||||
const base = Layer.mergeAll(
|
||||
location,
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
Reference.locationLayer,
|
||||
@@ -71,56 +71,22 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
systemContext,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
|
||||
const permissionsAndTools = ToolRegistry.layer.pipe(
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(base),
|
||||
)
|
||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
||||
const image = Image.layer.pipe(Layer.provide(services))
|
||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
Layer.provide(image),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(model),
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
)
|
||||
|
||||
// Kick off a background project copy refresh to update locations now that we
|
||||
// have a location
|
||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
||||
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
services,
|
||||
image,
|
||||
mutation,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
runner,
|
||||
builtInTools,
|
||||
referenceGuidance,
|
||||
projectCopyRefresh,
|
||||
).pipe(Layer.fresh)
|
||||
SystemContextBuiltIns.locationLayer,
|
||||
LocationMutation.locationLayer,
|
||||
PermissionV2.locationLayer,
|
||||
ToolOutputStore.locationLayer,
|
||||
ToolRegistry.locationLayer,
|
||||
Snapshot.locationLayer,
|
||||
Image.locationLayer,
|
||||
FileMutation.locationLayer,
|
||||
SkillGuidance.locationLayer,
|
||||
ReferenceGuidance.locationLayer,
|
||||
SessionTodo.locationLayer,
|
||||
QuestionV2.locationLayer,
|
||||
SessionRunnerModel.locationLayer,
|
||||
SessionRunnerLLM.locationLayer,
|
||||
BuiltInTools.locationLayer,
|
||||
).pipe(Layer.provideMerge(location), Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
@@ -136,7 +102,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Ripgrep.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
SessionStore.defaultLayer,
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
||||
|
||||
@@ -152,4 +152,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(Layer.orDie)
|
||||
|
||||
@@ -70,8 +70,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const origin = yield* git.remote(repo)
|
||||
const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
|
||||
const origin = yield* git.remote.get(repo)
|
||||
if (!origin) return undefined
|
||||
const normalized = url(origin)
|
||||
if (!normalized) return undefined
|
||||
@@ -102,22 +102,22 @@ export const layer = Layer.effect(
|
||||
return `${host.toLowerCase()}/${pathname}`
|
||||
}
|
||||
|
||||
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const root = (yield* git.roots(repo))[0]
|
||||
const root = Effect.fnUntraced(function* (repo: Git.Repository) {
|
||||
const root = (yield* git.history.rootCommits(repo))[0]
|
||||
return root ? ID.make(root) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.find(input)
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.directory,
|
||||
vcs: { type: "git" as const, store: repo.store },
|
||||
directory: repo.worktree,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { Git } from "../git"
|
||||
@@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: {
|
||||
git: Git.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
const repo = (sourceDirectory: AbsolutePath) =>
|
||||
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
|
||||
|
||||
return {
|
||||
id: StrategyID.make("git_worktree"),
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
||||
const repository = yield* input.git.repo.discover(options.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
|
||||
yield* input.git.worktree.create({ repository, directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
|
||||
const found = yield* input.git.find(options.directory)
|
||||
const found = yield* input.git.repo.discover(options.directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
|
||||
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
|
||||
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const found = yield* input.git.find(directory)
|
||||
const found = yield* input.git.repo.discover(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
|
||||
const entries = yield* input.git.worktreeList(found)
|
||||
const entries = yield* input.git.worktree.list(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
input.canonical(entry).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
|
||||
input.canonical(entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
|
||||
@@ -296,5 +296,11 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = Layer.merge(
|
||||
layer,
|
||||
Layer.effectDiscard(refreshAfterBoot).pipe(
|
||||
Layer.provide(layer),
|
||||
Layer.provide(PluginBoot.locationLayer),
|
||||
),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Effect, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { SessionV2 } from "../session"
|
||||
import { MessageDecodeError } from "../session/error"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionInput } from "../session/input"
|
||||
import { SessionMessage } from "../session/message"
|
||||
@@ -61,8 +60,6 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnav
|
||||
},
|
||||
) {}
|
||||
|
||||
export { MessageDecodeError }
|
||||
|
||||
export interface CreateInput {
|
||||
readonly id?: ID
|
||||
readonly agent?: Agent.ID
|
||||
@@ -112,8 +109,8 @@ export interface Interface {
|
||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
||||
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError>
|
||||
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError>
|
||||
readonly events: (input: EventsInput) => Stream.Stream<Event, NotFoundError>
|
||||
}
|
||||
|
||||
@@ -66,4 +66,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Layer.merge(PluginBoot.locationLayer, Reference.locationLayer)),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Repository } from "./repository"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
|
||||
export type Result = {
|
||||
@@ -142,15 +143,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
|
||||
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
|
||||
|
||||
const exists = yield* fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
|
||||
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
const origin = existing ? yield* git.remote.get(existing) : undefined
|
||||
const originReference = origin ? Repository.parse(origin) : undefined
|
||||
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
|
||||
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
||||
}
|
||||
|
||||
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
|
||||
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: input.refresh,
|
||||
@@ -158,86 +159,54 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const result = yield* git
|
||||
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
|
||||
.pipe(
|
||||
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return yield* new CloneFailedError({
|
||||
repository,
|
||||
message: resultMessage(result, `Failed to clone ${repository}`),
|
||||
yield* git.repo
|
||||
.clone({
|
||||
remote: input.reference.remote,
|
||||
directory: AbsolutePath.make(localPath),
|
||||
branch: input.branch,
|
||||
})
|
||||
}
|
||||
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* git
|
||||
.fetch(localPath)
|
||||
.pipe(
|
||||
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (fetch.exitCode !== 0) {
|
||||
return yield* new FetchFailedError({
|
||||
repository,
|
||||
message: resultMessage(fetch, `Failed to refresh ${repository}`),
|
||||
})
|
||||
}
|
||||
if (!existing) return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
|
||||
yield* git.sync
|
||||
.fetchRemotes(existing)
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
|
||||
if (input.branch) {
|
||||
const requestedBranch = input.branch
|
||||
const fetchBranch = yield* git
|
||||
.fetchBranch(localPath, requestedBranch)
|
||||
.pipe(
|
||||
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (fetchBranch.exitCode !== 0) {
|
||||
return yield* new FetchFailedError({
|
||||
repository,
|
||||
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
|
||||
})
|
||||
}
|
||||
yield* git.sync
|
||||
.fetchBranch(existing, { branch: requestedBranch })
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
|
||||
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
|
||||
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new CheckoutFailedError({
|
||||
repository,
|
||||
branch: requestedBranch,
|
||||
message: errorMessage(error),
|
||||
message: error.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (checkout.exitCode !== 0) {
|
||||
return yield* new CheckoutFailedError({
|
||||
repository,
|
||||
branch: requestedBranch,
|
||||
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const reset = yield* git
|
||||
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
|
||||
.pipe(
|
||||
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (reset.exitCode !== 0) {
|
||||
return yield* new ResetFailedError({
|
||||
repository,
|
||||
message: resultMessage(reset, `Failed to reset ${repository}`),
|
||||
})
|
||||
}
|
||||
yield* git.sync
|
||||
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
|
||||
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath,
|
||||
status,
|
||||
head: yield* git.head(localPath),
|
||||
branch: yield* git.branch(localPath),
|
||||
head: checkout ? yield* git.history.head(checkout) : undefined,
|
||||
branch: checkout ? yield* git.history.branch(checkout) : undefined,
|
||||
} satisfies Result
|
||||
}),
|
||||
`repository-cache:${localPath}`,
|
||||
@@ -275,17 +244,17 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
|
||||
)
|
||||
}
|
||||
|
||||
const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
|
||||
const resetTarget = Effect.fnUntraced(function* (
|
||||
git: Git.Interface,
|
||||
repository: Git.Repository,
|
||||
requestedBranch?: string,
|
||||
) {
|
||||
if (requestedBranch) return `origin/${requestedBranch}`
|
||||
const remoteHead = yield* git.remoteHead(cwd)
|
||||
if (remoteHead) return remoteHead
|
||||
const currentBranch = yield* git.branch(cwd)
|
||||
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
|
||||
if (remoteHead) return `origin/${remoteHead}`
|
||||
const currentBranch = yield* git.history.branch(repository)
|
||||
if (currentBranch) return `origin/${currentBranch}`
|
||||
return "HEAD"
|
||||
})
|
||||
|
||||
function resultMessage(result: Git.Result, fallback: string) {
|
||||
return result.stderr.trim() || result.text.trim() || fallback
|
||||
}
|
||||
|
||||
export * as RepositoryCache from "./repository-cache"
|
||||
|
||||
@@ -26,9 +26,11 @@ import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
import { File } from "./file"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -93,14 +95,17 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
||||
},
|
||||
) {}
|
||||
|
||||
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
|
||||
export { ContextSnapshotDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
export type Error = NotFoundError | OperationUnavailableError | PromptConflictError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
@@ -114,14 +119,14 @@ export interface Interface {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<SessionMessage.Message | undefined>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
readonly events: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: EventV2.Cursor
|
||||
@@ -157,18 +162,37 @@ export interface Interface {
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly revert: {
|
||||
readonly preview: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<
|
||||
readonly File.Diff[],
|
||||
NotFoundError | MessageNotFoundError | Snapshot.Error
|
||||
>
|
||||
readonly commit: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | MessageNotFoundError | Snapshot.Error>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(() => import("./location-layer")).pipe(
|
||||
Effect.map(({ LocationServiceMap }) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const scope = yield* Effect.scope
|
||||
@@ -186,15 +210,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
@@ -419,18 +435,45 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
),
|
||||
revert: {
|
||||
preview: Effect.fn("V2Session.revert.preview")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionRevert.preview(input).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionRevert.commit(input).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
return result
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.promise(() => import("./location-layer")).pipe(
|
||||
Effect.map(({ LocationServiceMap }) =>
|
||||
layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
|
||||
"Session.ContextSnapshotDecodeError",
|
||||
{
|
||||
|
||||
@@ -204,6 +204,7 @@ export namespace Step {
|
||||
}),
|
||||
}),
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
files: RelativePath.pipe(Schema.Array, Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
@@ -468,6 +469,16 @@ export namespace Compaction {
|
||||
export type Ended = typeof Ended.Type
|
||||
}
|
||||
|
||||
export const Reverted = EventV2.define({
|
||||
type: "session.next.reverted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
},
|
||||
})
|
||||
export type Reverted = typeof Reverted.Type
|
||||
|
||||
const DurableDefinitions = [
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
@@ -496,6 +507,7 @@ const DurableDefinitions = [
|
||||
Retried,
|
||||
Compaction.Started,
|
||||
Compaction.Ended,
|
||||
Reverted,
|
||||
] as const
|
||||
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||
@@ -53,15 +52,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const [epoch, compaction] = yield* Effect.all(
|
||||
|
||||
@@ -214,7 +214,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
draft.finish = event.data.finish
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
|
||||
if (event.data.snapshot || event.data.files)
|
||||
draft.snapshot = {
|
||||
...draft.snapshot,
|
||||
end: event.data.snapshot,
|
||||
files: event.data.files ? Array.from(event.data.files) : undefined,
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.next.step.failed": (event) => {
|
||||
@@ -382,6 +387,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.reverted": () => Effect.void,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
|
||||
snapshot: Schema.Struct({
|
||||
start: Schema.String.pipe(Schema.optional),
|
||||
end: Schema.String.pipe(Schema.optional),
|
||||
files: SessionEvent.Step.Ended.data.fields.files,
|
||||
}).pipe(Schema.optional),
|
||||
finish: Schema.String.pipe(Schema.optional),
|
||||
cost: Schema.Finite.pipe(Schema.optional),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector"
|
||||
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { and, desc, eq, gt, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
@@ -13,7 +13,7 @@ import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionContextEpoch } from "./context-epoch"
|
||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
@@ -444,6 +444,52 @@ export const layer = Layer.effectDiscard(
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
||||
})
|
||||
})
|
||||
yield* events.project(SessionEvent.Reverted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.id, event.data.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`)
|
||||
yield* db
|
||||
.delete(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
gt(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.delete(SessionInputTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.session_id, event.data.sessionID),
|
||||
or(
|
||||
gt(SessionInputTable.admitted_seq, boundary.seq),
|
||||
gt(SessionInputTable.promoted_seq, boundary.seq),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export * as SessionRevert from "./revert"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { RelativePath } from "../schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
|
||||
"Session.MessageNotFoundError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
interface Input {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
}
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (input: Input) {
|
||||
const db = (yield* Database.Service).db
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* new MessageNotFoundError(input)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
if (message.type !== "assistant" || !message.snapshot?.start) continue
|
||||
for (const file of message.snapshot.files ?? [])
|
||||
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
export const preview = Effect.fn("SessionRevert.preview")(function* (input: Input) {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.preview({ files: yield* plan(input) })
|
||||
})
|
||||
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (input: Input & { readonly files?: boolean }) {
|
||||
const files = yield* plan(input)
|
||||
const snapshot = yield* Snapshot.Service
|
||||
if (input.files !== false) yield* snapshot.restore({ files })
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Reverted, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import type { ContextSnapshotDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
@@ -12,7 +12,6 @@ import type { ToolOutputStore } from "../../tool-output-store"
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
|
||||
@@ -32,6 +32,8 @@ import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { type RunError, Service } from "./index"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
@@ -59,7 +61,8 @@ import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||
* - [x] Stream exactly one `llm.stream(request)` provider turn.
|
||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||
* - [x] Persist snapshots and changed paths at settled step boundaries.
|
||||
* - [ ] Persist retry notices incrementally as they arrive.
|
||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||
*
|
||||
* - Tool settlement and continuation
|
||||
@@ -100,6 +103,7 @@ export const layer = Layer.effect(
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||
const config = yield* Config.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
@@ -229,6 +233,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
@@ -237,6 +242,7 @@ export const layer = Layer.effect(
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
},
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const withPublication = Semaphore.makeUnsafe(1).withPermit
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
@@ -333,6 +339,32 @@ export const layer = Layer.effect(
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !publisher.hasProviderError()) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: endSnapshot })
|
||||
.pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to list step snapshot files", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
yield* withPublication(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: stepSettlement.finish,
|
||||
cost: 0,
|
||||
tokens: stepSettlement.tokens,
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
@@ -406,3 +438,18 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
AgentV2.locationLayer,
|
||||
ToolRegistry.locationLayer,
|
||||
SessionRunnerModel.locationLayer,
|
||||
SystemContextBuiltIns.locationLayer,
|
||||
SkillGuidance.locationLayer,
|
||||
ReferenceGuidance.locationLayer,
|
||||
Config.locationLayer,
|
||||
Snapshot.locationLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -161,4 +161,4 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provideMerge(Layer.merge(Catalog.locationLayer, PluginBoot.locationLayer)))
|
||||
|
||||
@@ -10,6 +10,7 @@ type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: string
|
||||
readonly model: ModelV2.Ref
|
||||
readonly snapshot?: string
|
||||
}
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
@@ -66,6 +67,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let providerFailed = false
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: string
|
||||
readonly tokens: ReturnType<typeof tokens>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
@@ -74,6 +81,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
...input,
|
||||
assistantMessageID,
|
||||
timestamp: yield* timestamp,
|
||||
snapshot: input.snapshot,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
@@ -375,14 +383,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
finish: event.reason,
|
||||
cost: 0,
|
||||
tokens: tokens(event.usage),
|
||||
})
|
||||
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
@@ -405,6 +407,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
failUnsettledTools,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SessionTable = sqliteTable(
|
||||
summary_additions: integer(),
|
||||
summary_deletions: integer(),
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
|
||||
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
|
||||
cost: real().notNull().default(0),
|
||||
tokens_input: integer().notNull().default(0),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { SessionHistory } from "./history"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
@@ -12,11 +11,11 @@ import { fromRow } from "./info"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[]>
|
||||
readonly runnerContext: (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
) => Effect.Effect<SessionMessage.Message[]>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
|
||||
@@ -89,3 +89,4 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
export const locationLayer = layer
|
||||
|
||||
@@ -157,4 +157,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(SkillDiscovery.layer))
|
||||
|
||||
@@ -73,4 +73,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Layer.merge(PluginBoot.locationLayer, SkillV2.locationLayer)),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,201 @@
|
||||
export namespace Snapshot {
|
||||
export type FileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
export * as Snapshot from "./snapshot"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { File } from "./file"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface CompareInput {
|
||||
readonly from: ID
|
||||
readonly to: ID
|
||||
}
|
||||
|
||||
export interface DiffInput extends CompareInput {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface RestoreInput {
|
||||
/** Paths are relative to the project root. */
|
||||
readonly files: ReadonlyMap<RelativePath, ID>
|
||||
}
|
||||
|
||||
export interface PreviewInput extends RestoreInput {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Capture the current Location-scoped state. */
|
||||
readonly capture: () => Effect.Effect<ID | undefined>
|
||||
readonly files: (input: CompareInput) => Effect.Effect<readonly RelativePath[], Error>
|
||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Snapshot") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const source = yield* git.repo.discover(location.project.directory)
|
||||
const worktree = source
|
||||
? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
|
||||
: location.project.directory
|
||||
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))
|
||||
|
||||
const scope = Effect.fnUntraced(function* () {
|
||||
const relative = path.relative(worktree, location.directory)
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative))
|
||||
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
|
||||
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
|
||||
})
|
||||
|
||||
const repository = Effect.fnUntraced(function* () {
|
||||
if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" })
|
||||
if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
|
||||
return new Git.Repository({
|
||||
worktree,
|
||||
gitDirectory,
|
||||
commonDirectory: gitDirectory,
|
||||
})
|
||||
return yield* git.repo.create({
|
||||
worktree,
|
||||
gitDirectory,
|
||||
seed: source,
|
||||
}).pipe(Effect.mapError((cause) => failure("capture", cause)))
|
||||
})
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (location.vcs?.type !== "git") return false
|
||||
return Config.latest(yield* config.entries(), "snapshots") !== false
|
||||
})
|
||||
|
||||
const capture = Effect.fn("Snapshot.capture")(function* () {
|
||||
if (!(yield* enabled())) return undefined
|
||||
return yield* Effect.gen(function* () {
|
||||
const repo = yield* repository()
|
||||
return ID.make(
|
||||
yield* git.tree.capture({
|
||||
repository: repo,
|
||||
scopes: [yield* scope()],
|
||||
ignores: source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) }
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
return yield* git.tree
|
||||
.files(yield* compare("files", input))
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
return yield* git.tree
|
||||
.diff({ ...(yield* compare("diff", input)), context: input.context })
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) {
|
||||
const files = new Map<RelativePath, Git.TreeID>()
|
||||
for (const [file, snapshot] of input.files) {
|
||||
const absolute = path.resolve(worktree, file)
|
||||
if (!FSUtil.contains(worktree, absolute))
|
||||
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
|
||||
files.set(file, Git.TreeID.make(snapshot))
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
const files = yield* plan("preview", input)
|
||||
const current = yield* git.tree.capture({
|
||||
repository: repo,
|
||||
scopes: Array.from(files.keys()),
|
||||
ignores: source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
}).pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
return yield* git.tree
|
||||
.preview({
|
||||
repository: repo,
|
||||
current,
|
||||
files,
|
||||
context: input.context,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.restore({ repository: repo, files: yield* plan("restore", input) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
return Service.of({ capture, files, diff, preview, restore })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
capture: () => Effect.succeed(undefined),
|
||||
files: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
preview: () => Effect.succeed([]),
|
||||
restore: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
function failure(operation: Error["operation"], cause: unknown) {
|
||||
if (cause instanceof Error && cause.operation === operation) return cause
|
||||
return new Error({
|
||||
operation,
|
||||
message: cause instanceof globalThis.Error ? cause.message : String(cause),
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
/** Legacy persisted session diff shape. */
|
||||
export type LegacyFileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
/** Runs retention scanning once globally rather than once per active Location. */
|
||||
export const cleanupLayer = Layer.effectDiscard(
|
||||
|
||||
@@ -14,6 +14,16 @@ import { TodoWriteTool } from "./todowrite"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Config } from "../config"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { Image } from "../image"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||
@@ -41,4 +51,19 @@ export const locationLayer = Layer.mergeAll(
|
||||
WebFetchTool.layer,
|
||||
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
||||
WriteTool.layer,
|
||||
).pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
ToolRegistry.locationLayer,
|
||||
PermissionV2.locationLayer,
|
||||
Config.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
LocationMutation.locationLayer,
|
||||
FileMutation.locationLayer,
|
||||
QuestionV2.locationLayer,
|
||||
SessionTodo.locationLayer,
|
||||
Image.locationLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -137,3 +137,5 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(ToolOutputStore.locationLayer))
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -16,14 +16,16 @@ describe("Git", () => {
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const target = path.join(fixture.root, "checkout")
|
||||
const result = yield* git.clone({ remote: fixture.remote, target })
|
||||
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
|
||||
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(yield* git.origin(target)).toBe(fixture.remote)
|
||||
expect(yield* git.head(target)).toBeString()
|
||||
expect(yield* git.branch(target)).toBe("main")
|
||||
expect(yield* git.remoteHead(target)).toBe("origin/main")
|
||||
expect(yield* git.remote.get(repository)).toBe(fixture.remote)
|
||||
expect(yield* git.history.head(repository)).toBeString()
|
||||
expect(yield* git.history.branch(repository)).toBe("main")
|
||||
expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main")
|
||||
expect(repository.worktree).toBe(target)
|
||||
expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git")))
|
||||
expect(repository.commonDirectory).toBe(repository.gitDirectory)
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
|
||||
}),
|
||||
),
|
||||
@@ -33,19 +35,19 @@ describe("Git", () => {
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const target = path.join(fixture.root, "checkout")
|
||||
yield* git.clone({ remote: fixture.remote, target })
|
||||
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
|
||||
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
|
||||
|
||||
yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
|
||||
expect((yield* git.fetch(target)).exitCode).toBe(0)
|
||||
expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
|
||||
yield* git.sync.fetchRemotes(repository)
|
||||
yield* git.sync.resetHard(repository, "origin/main")
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
|
||||
|
||||
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
|
||||
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
|
||||
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
|
||||
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
|
||||
expect(yield* git.branch(target)).toBe("feature/docs")
|
||||
yield* git.sync.fetchBranch(repository, { branch: "feature/docs" })
|
||||
yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" })
|
||||
yield* git.sync.resetHard(repository, "origin/feature/docs")
|
||||
expect(yield* git.history.branch(repository)).toBe("feature/docs")
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
|
||||
}),
|
||||
),
|
||||
@@ -90,17 +92,70 @@ describe("Git worktrees", () => {
|
||||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const git = yield* Git.Service
|
||||
const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
|
||||
const repo = yield* git.repo.discover(directory)
|
||||
if (!repo) throw new Error("Repository not found")
|
||||
|
||||
yield* git.worktreeCreate({ repo, directory: worktree })
|
||||
yield* git.worktree.create({ repository: repo, directory: worktree })
|
||||
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
|
||||
const linked = yield* git.find(worktree)
|
||||
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||
expect(linked?.store).toBe(repo.store)
|
||||
expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true)
|
||||
const linked = yield* git.repo.discover(worktree)
|
||||
expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||
expect(linked?.commonDirectory).toBe(repo.commonDirectory)
|
||||
expect(linked?.gitDirectory).not.toBe(repo.gitDirectory)
|
||||
if (!linked) throw new Error("Linked worktree not found")
|
||||
yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
|
||||
yield* git.worktree.remove({ repository: linked, directory: worktree, force: false })
|
||||
expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Git trees", () => {
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(root.path)
|
||||
await fs.mkdir(path.join(root.path, "scope"))
|
||||
await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n")
|
||||
await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n")
|
||||
await $`git add .`.cwd(root.path).quiet()
|
||||
await $`git commit -m initial`.cwd(root.path).quiet()
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
const storage = AbsolutePath.make(path.join(root.path, ".snapshot"))
|
||||
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make("scope")] })
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n")
|
||||
await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n")
|
||||
await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make("scope")] })
|
||||
|
||||
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
|
||||
RelativePath.make("scope/added.txt"),
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
|
||||
expect(diffs.map((item) => [item.path, item.status])).toEqual([
|
||||
[RelativePath.make("scope/added.txt"), "added"],
|
||||
[RelativePath.make("scope/tracked.txt"), "modified"],
|
||||
])
|
||||
|
||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* git.tree.restore({ repository, files })
|
||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||
expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
@@ -33,6 +34,7 @@ const project = Project.layer.pipe(
|
||||
)
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(database),
|
||||
Layer.provide(events),
|
||||
Layer.provide(project),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PermissionTable } from "@opencode-ai/core/permission/sql"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
@@ -26,6 +27,7 @@ const current = Layer.succeed(
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -38,6 +39,7 @@ const projects = Layer.succeed(
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
||||
@@ -8,8 +8,9 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
@@ -44,6 +45,46 @@ const assistantRow = (
|
||||
}
|
||||
|
||||
describe("SessionProjector", () => {
|
||||
it.effect("projects a committed revert by removing the message suffix", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const boundary = SessionMessage.ID.make("msg_boundary")
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
||||
.run()
|
||||
|
||||
yield* (yield* EventV2.Service).publish(SessionEvent.Reverted, {
|
||||
sessionID,
|
||||
messageID: boundary,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
})
|
||||
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.all()).map((row) => row.id),
|
||||
).toEqual([boundary])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders projected messages and context by durable aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
@@ -113,6 +154,7 @@ describe("SessionProjector", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
@@ -524,6 +566,8 @@ describe("SessionProjector", () => {
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
snapshot: "tree_after",
|
||||
files: [RelativePath.make("src/index.ts")],
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
@@ -540,6 +584,7 @@ describe("SessionProjector", () => {
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
snapshot: { end: "tree_after", files: [RelativePath.make("src/index.ts")] },
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
@@ -47,6 +48,7 @@ const execution = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
||||
@@ -22,10 +22,12 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
@@ -86,6 +88,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
Layer.provide(config),
|
||||
Layer.provide(Snapshot.noopLayer),
|
||||
)
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
const execution = Layer.effect(
|
||||
@@ -101,6 +104,7 @@ const execution = Layer.effect(
|
||||
),
|
||||
).pipe(Layer.provide(coordinator))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
||||
@@ -125,3 +125,12 @@ test("old success event data containing result still decodes", () => {
|
||||
})
|
||||
expect(decoded.result).toMatchObject({ type: "content" })
|
||||
})
|
||||
|
||||
test("step finish records settlement without publishing step ended", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
|
||||
|
||||
expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
@@ -49,6 +50,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
@@ -247,6 +249,7 @@ const runner = SessionRunnerLLM.layer.pipe(
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
Layer.provide(config),
|
||||
Layer.provide(Snapshot.noopLayer),
|
||||
)
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
const execution = Layer.effect(
|
||||
@@ -262,6 +265,7 @@ const execution = Layer.effect(
|
||||
),
|
||||
).pipe(Layer.provide(coordinator))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
@@ -1788,6 +1792,19 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
|
||||
expect(executions).toEqual(["hello"])
|
||||
const { db } = yield* Database.Service
|
||||
const events = yield* db
|
||||
.select({ seq: EventTable.seq, type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
const success = events.find((event) => event.type === "session.next.tool.success.1")
|
||||
const ended = events.find((event) => event.type === "session.next.step.ended.2")
|
||||
expect(success).toBeDefined()
|
||||
expect(ended).toBeDefined()
|
||||
if (!success || !ended) throw new Error("Missing tool settlement events")
|
||||
expect(ended.seq).toBeGreaterThan(success.seq)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Echo this" },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
describe("Snapshot", () => {
|
||||
testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const location = path.join(project, "scope")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(location, { recursive: true })
|
||||
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
|
||||
await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
const layer = snapshotLayer(tmp.path, location)
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
expect(before).toBeDefined()
|
||||
if (!before) return
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(location, "tracked.txt"), "two\n")
|
||||
await fs.writeFile(path.join(location, "added.txt"), "added\n")
|
||||
await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n")
|
||||
})
|
||||
const after = yield* snapshot.capture()
|
||||
expect(after).toBeDefined()
|
||||
if (!after) return
|
||||
|
||||
expect(yield* snapshot.files({ from: before, to: after })).toEqual([
|
||||
RelativePath.make("scope/added.txt"),
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* snapshot.restore({ files: plan })
|
||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||
expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const linked = path.join(tmp.path, "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
const capture = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
|
||||
expect(yield* capture(project)).toBeDefined()
|
||||
expect(yield* capture(linked)).toBeDefined()
|
||||
|
||||
const projectID = yield* Effect.gen(function* () {
|
||||
return (yield* Location.Service).project.id
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project))))).toBeDefined()
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked))))).toBeDefined()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
)
|
||||
return Snapshot.layer.pipe(
|
||||
Layer.provide(location),
|
||||
Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
|
||||
}
|
||||
@@ -962,6 +962,27 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message/{messageID}/revert", "v2.session.revert.preview")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/message/{messageID}/revert", {
|
||||
sessionID: "ses_httpapi_missing",
|
||||
messageID: "msg_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/message/{messageID}/revert", "v2.session.revert.commit")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/message/{messageID}/revert", {
|
||||
sessionID: "ses_httpapi_missing",
|
||||
messageID: "msg_httpapi_missing",
|
||||
}),
|
||||
headers: { ...ctx.headers(), "content-type": "application/json" },
|
||||
body: { files: false },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -353,6 +353,10 @@ import type {
|
||||
V2SessionQuestionRejectResponses,
|
||||
V2SessionQuestionReplyErrors,
|
||||
V2SessionQuestionReplyResponses,
|
||||
V2SessionRevertCommitErrors,
|
||||
V2SessionRevertCommitResponses,
|
||||
V2SessionRevertPreviewErrors,
|
||||
V2SessionRevertPreviewResponses,
|
||||
V2SessionWaitErrors,
|
||||
V2SessionWaitResponses,
|
||||
V2SkillListErrors,
|
||||
@@ -5064,6 +5068,83 @@ export class Agent extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Revert extends HeyApiClient {
|
||||
/**
|
||||
* Preview message revert
|
||||
*
|
||||
* Preview the filesystem changes required to revert all assistant work after a message.
|
||||
*/
|
||||
public preview<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "messageID" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<
|
||||
V2SessionRevertPreviewResponses,
|
||||
V2SessionRevertPreviewErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/session/{sessionID}/message/{messageID}/revert",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit message revert
|
||||
*
|
||||
* Permanently remove all history after a message and optionally restore affected files.
|
||||
*/
|
||||
public commit<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
files?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "messageID" },
|
||||
{ in: "body", key: "files" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
V2SessionRevertCommitResponses,
|
||||
V2SessionRevertCommitErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/session/{sessionID}/message/{messageID}/revert",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Permission2 extends HeyApiClient {
|
||||
/**
|
||||
* List session permission requests
|
||||
@@ -5469,6 +5550,11 @@ export class Session3 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
private _revert?: Revert
|
||||
get revert(): Revert {
|
||||
return (this._revert ??= new Revert({ client: this.client }))
|
||||
}
|
||||
|
||||
private _permission?: Permission2
|
||||
get permission(): Permission2 {
|
||||
return (this._permission ??= new Permission2({ client: this.client }))
|
||||
|
||||
@@ -46,6 +46,7 @@ export type Event =
|
||||
| EventSessionNextCompactionStarted
|
||||
| EventSessionNextCompactionDelta
|
||||
| EventSessionNextCompactionEnded
|
||||
| EventSessionNextReverted
|
||||
| EventMessagePartDelta
|
||||
| EventSessionDiff
|
||||
| EventSessionError
|
||||
@@ -960,6 +961,7 @@ export type GlobalEvent = {
|
||||
}
|
||||
}
|
||||
snapshot?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1201,6 +1203,15 @@ export type GlobalEvent = {
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.reverted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "message.part.delta"
|
||||
@@ -1659,6 +1670,7 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionNextRetried
|
||||
| SyncEventSessionNextCompactionStarted
|
||||
| SyncEventSessionNextCompactionEnded
|
||||
| SyncEventSessionNextReverted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2744,6 +2756,13 @@ export type ServiceUnavailableError = {
|
||||
service?: string
|
||||
}
|
||||
|
||||
export type MessageNotFoundError = {
|
||||
_tag: "MessageNotFoundError"
|
||||
sessionID: string
|
||||
messageID: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type UnknownError1 = {
|
||||
_tag: "UnknownError"
|
||||
message: string
|
||||
@@ -3292,6 +3311,7 @@ export type SyncEventSessionNextStepEnded = {
|
||||
}
|
||||
}
|
||||
snapshot?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3590,6 +3610,22 @@ export type SyncEventSessionNextCompactionEnded = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextReverted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.next.reverted.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigV2ReferenceGit = {
|
||||
repository: string
|
||||
branch?: string
|
||||
@@ -3699,6 +3735,14 @@ export type SessionInputAdmitted = {
|
||||
promotedSeq?: number
|
||||
}
|
||||
|
||||
export type FileDiff = {
|
||||
path: string
|
||||
status: "added" | "modified" | "deleted"
|
||||
additions: number
|
||||
deletions: number
|
||||
patch: string
|
||||
}
|
||||
|
||||
export type SessionMessageAgentSwitched = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -3891,6 +3935,7 @@ export type SessionMessageAssistant = {
|
||||
snapshot?: {
|
||||
start?: string
|
||||
end?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
finish?: string
|
||||
cost?: number
|
||||
@@ -4472,6 +4517,7 @@ export type EventSessionNextStepEnded = {
|
||||
}
|
||||
}
|
||||
snapshot?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4732,6 +4778,16 @@ export type EventSessionNextCompactionEnded = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextReverted = {
|
||||
id: string
|
||||
type: "session.next.reverted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMessagePartDelta = {
|
||||
id: string
|
||||
type: "message.part.delta"
|
||||
@@ -9645,6 +9701,90 @@ export type V2SessionWaitResponses = {
|
||||
|
||||
export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses]
|
||||
|
||||
export type V2SessionRevertPreviewData = {
|
||||
body?: never
|
||||
path: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/message/{messageID}/revert"
|
||||
}
|
||||
|
||||
export type V2SessionRevertPreviewErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* MessageNotFoundError | SessionNotFoundError
|
||||
*/
|
||||
404: MessageNotFoundError | SessionNotFoundError
|
||||
/**
|
||||
* UnknownError
|
||||
*/
|
||||
500: UnknownError1
|
||||
}
|
||||
|
||||
export type V2SessionRevertPreviewError = V2SessionRevertPreviewErrors[keyof V2SessionRevertPreviewErrors]
|
||||
|
||||
export type V2SessionRevertPreviewResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: Array<FileDiff>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2SessionRevertPreviewResponse = V2SessionRevertPreviewResponses[keyof V2SessionRevertPreviewResponses]
|
||||
|
||||
export type V2SessionRevertCommitData = {
|
||||
body: {
|
||||
files?: boolean
|
||||
}
|
||||
path: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/message/{messageID}/revert"
|
||||
}
|
||||
|
||||
export type V2SessionRevertCommitErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* MessageNotFoundError | SessionNotFoundError
|
||||
*/
|
||||
404: MessageNotFoundError | SessionNotFoundError
|
||||
/**
|
||||
* UnknownError
|
||||
*/
|
||||
500: UnknownError1
|
||||
}
|
||||
|
||||
export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors]
|
||||
|
||||
export type V2SessionRevertCommitResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses]
|
||||
|
||||
export type V2SessionContextData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -61,6 +61,16 @@ export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoun
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
|
||||
"MessageNotFoundError",
|
||||
{
|
||||
sessionID: Schema.String,
|
||||
messageID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
|
||||
"InvalidCursorError",
|
||||
{ message: Schema.String },
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
@@ -19,6 +20,7 @@ import { SessionLocationMiddleware } from "../middleware/session-location"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { File } from "@opencode-ai/core/file"
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
@@ -191,6 +193,37 @@ export const SessionGroup = HttpApiGroup.make("server.session")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.revert.preview", "/api/session/:sessionID/message/:messageID/revert", {
|
||||
params: { sessionID: SessionV2.ID, messageID: SessionMessage.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(File.Diff) }),
|
||||
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
})
|
||||
.middleware(SessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.revert.preview",
|
||||
summary: "Preview message revert",
|
||||
description: "Preview the filesystem changes required to revert all assistant work after a message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/message/:messageID/revert", {
|
||||
params: { sessionID: SessionV2.ID, messageID: SessionMessage.ID },
|
||||
payload: Schema.Struct({ files: Schema.Boolean.pipe(Schema.optional) }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
})
|
||||
.middleware(SessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.revert.commit",
|
||||
summary: "Commit message revert",
|
||||
description: "Permanently remove all history after a message and optionally restore affected files.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
|
||||
params: { sessionID: SessionV2.ID },
|
||||
|
||||
@@ -54,20 +54,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SessionsCursor } from "../groups/session"
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
@@ -174,22 +175,30 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.context",
|
||||
"session.revert.preview",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.context(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
data: yield* session.revert.preview(ctx.params).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
return Effect.logError("failed to preview session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
@@ -204,5 +213,60 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.revert.commit",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.revert.commit({ ...ctx.params, files: ctx.payload.files }).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to commit session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.context",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.context(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user