| 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, RelativePath } from "./schema" |
| import { FSUtil } from "./fs-util" |
| import { AppProcess } from "./process" |
| import { makeGlobalNode } from "./effect/app-node" |
| import { File } from "./file" |
| import { KeyedMutex } from "./effect/keyed-mutex" |
|
|
| 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"]), |
| message: Schema.String, |
| directory: Schema.optional(AbsolutePath), |
| forceRequired: Schema.optional(Schema.Boolean), |
| cause: Schema.optional(Schema.Defect()), |
| }) {} |
|
|
| export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", { |
| operation: Schema.Literals(["capture", "apply", "reset"]), |
| directory: AbsolutePath, |
| message: Schema.String, |
| cause: Schema.optional(Schema.Defect()), |
| }) {} |
|
|
| export interface Interface { |
| 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: { |
| |
| readonly refresh: (input: { |
| repository: Repository |
| scope: RelativePath |
| ignores?: Repository |
| maximumUntrackedFileBytes?: number |
| }) => Effect.Effect<{ readonly skipped: readonly RelativePath[] }, OperationError> |
| readonly ignored: (input: { |
| repository: Repository |
| paths: readonly RelativePath[] |
| }) => Effect.Effect<ReadonlySet<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> |
| readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect<void, OperationError> |
| } |
| } |
|
|
| export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {} |
|
|
| const layer = Layer.effect( |
| Service, |
| 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 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)), |
| ) |
| if (!dotgit) return undefined |
|
|
| 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 (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined |
|
|
| 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.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.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") |
| .map((item) => item.trim()) |
| .filter(Boolean) |
| .toSorted() |
| }) |
|
|
| 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 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 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 result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || 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 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.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.sync.fetchRemotes")(function* ( |
| repository: Repository, |
| input: { prune?: boolean } = {}, |
| ) { |
| yield* operation("fetch", repository.worktree, ["fetch", "--all", ...(input.prune === false ? [] : ["--prune"])]) |
| }) |
|
|
| 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.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.sync.resetHard")(function* (repository: Repository, revision: string) { |
| yield* operation("reset", repository.worktree, ["reset", "--hard", revision]) |
| }) |
|
|
| 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, |
| }), |
| ), |
| ) |
| 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 ignored = Effect.fn("Git.index.ignored")(function* (input: { |
| repository: Repository |
| paths: readonly RelativePath[] |
| }) { |
| if (!input.paths.length) return new Set<RelativePath>() |
| const result = yield* proc |
| .run( |
| ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), { |
| cwd: input.repository.worktree, |
| extendEnv: true, |
| }), |
| { stdin: input.paths.join("\0") + "\0" }, |
| ) |
| .pipe( |
| Effect.mapError( |
| (cause) => |
| new OperationError({ |
| operation: "list_files", |
| directory: input.repository.worktree, |
| message: cause.message, |
| cause, |
| }), |
| ), |
| ) |
| if (result.exitCode !== 0 && result.exitCode !== 1) |
| return yield* new OperationError({ |
| operation: "list_files", |
| directory: input.repository.worktree, |
| message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths", |
| }) |
| return new Set( |
| result.stdout |
| .toString("utf8") |
| .split("\0") |
| .filter(Boolean) |
| .map((file) => RelativePath.make(file)), |
| ) |
| }) |
|
|
| 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 checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) => |
| locked( |
| input.repository, |
| Effect.gen(function* () { |
| yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree]) |
| yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"]) |
| }), |
| ), |
| ) |
|
|
| 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( |
| input.repository.worktree, |
| proc, |
| )(["diff", "--binary", "HEAD", "--", scope]).pipe( |
| 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: input.path, |
| message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes", |
| }) |
| } |
|
|
| const untracked = yield* execute( |
| input.repository.worktree, |
| proc, |
| )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe( |
| 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: 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( |
| input.repository.worktree, |
| proc, |
| )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe( |
| Effect.mapError( |
| (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), |
| ), |
| Effect.flatMap((result) => |
| |
| result.exitCode === 0 || result.exitCode === 1 |
| ? Effect.succeed(result.text) |
| : Effect.fail( |
| new PatchError({ |
| operation: "capture", |
| directory: input.path, |
| message: |
| result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`, |
| }), |
| ), |
| ), |
| ), |
| ) |
| return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n")) |
| }) |
|
|
| 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.path, |
| extendEnv: true, |
| stdin: Stream.make(new TextEncoder().encode(input.changes)), |
| }), |
| ) |
| .pipe( |
| Effect.mapError( |
| (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.path, |
| message: |
| result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes", |
| }) |
| }) |
|
|
| 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 (restore.exitCode !== 0) { |
| return yield* new PatchError({ |
| operation: "reset", |
| directory: input.path, |
| message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes", |
| }) |
| } |
| 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: input.path, |
| message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", |
| }) |
| }) |
|
|
| const worktreeRun = Effect.fnUntraced(function* ( |
| operation: "create" | "remove" | "list", |
| repository: Repository, |
| args: string[], |
| worktreeDirectory?: AbsolutePath, |
| cwd = repository.worktree, |
| ) { |
| const result = yield* proc |
| .run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" })) |
| .pipe( |
| Effect.mapError( |
| (cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }), |
| ), |
| ) |
| if (result.exitCode === 0) return result.stdout.toString("utf8") |
| const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed" |
| return yield* new WorktreeError({ |
| operation, |
| directory: worktreeDirectory, |
| message, |
| forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message), |
| }) |
| }) |
|
|
| 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.worktree.remove")(function* (input: { |
| repository: Repository |
| directory: AbsolutePath |
| force: boolean |
| }) { |
| yield* worktreeRun( |
| "remove", |
| input.repository, |
| ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory], |
| input.directory, |
| input.repository.commonDirectory, |
| ) |
| }) |
|
|
| 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, index) => |
| new Worktree({ |
| directory: AbsolutePath.make(resolvePath(repository.worktree, line.slice("worktree ".length).trim())), |
| kind: index === 0 ? "main" : "linked", |
| }), |
| ) |
| }) |
|
|
| return Service.of({ |
| 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, ignored }, |
| tree: { |
| capture: captureTree, |
| write: writeTree, |
| files: treeFiles, |
| diff: treeDiff, |
| preview, |
| restore, |
| checkout: checkoutTree, |
| }, |
| }) |
| }), |
| ) |
|
|
| export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) |
|
|
| interface Result { |
| readonly exitCode: number |
| readonly text: string |
| readonly stderr: string |
| } |
|
|
| function run(cwd: string, proc: AppProcess.Interface) { |
| return (args: string[]) => |
| execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" }))) |
| } |
|
|
| function execute(cwd: string, proc: AppProcess.Interface) { |
| return (args: string[]) => |
| proc |
| .run( |
| ChildProcess.make("git", args, { |
| cwd, |
| extendEnv: true, |
| stdin: "ignore", |
| }), |
| ) |
| .pipe( |
| Effect.map( |
| (result) => |
| ({ |
| exitCode: result.exitCode, |
| text: result.stdout.toString("utf8"), |
| stderr: result.stderr.toString("utf8"), |
| }) satisfies Result, |
| ), |
| ) |
| } |
|
|
| function resolvePath(cwd: string, value: string) { |
| const trimmed = value.replace(/[\r\n]+$/, "") |
| if (!trimmed) return cwd |
| const normalized = FSUtil.windowsPath(trimmed) |
| if (path.isAbsolute(normalized)) return path.normalize(normalized) |
| return path.resolve(cwd, normalized) |
| } |
|
|