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("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()("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("Git.Worktree")({ directory: AbsolutePath, kind: Schema.Literals(["main", "linked"]), }) {} export class WorktreeError extends Schema.TaggedErrorClass()("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()("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 readonly clone: (input: { remote: string directory: AbsolutePath branch?: string depth?: number }) => Effect.Effect readonly create: (input: { worktree: AbsolutePath gitDirectory: AbsolutePath seed?: Repository }) => Effect.Effect } readonly remote: { readonly get: (repository: Repository, name?: string) => Effect.Effect } readonly history: { readonly head: (repository: Repository) => Effect.Effect readonly branch: (repository: Repository) => Effect.Effect readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect readonly rootCommits: (repository: Repository) => Effect.Effect } readonly sync: { readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect readonly fetchBranch: ( repository: Repository, input: { remote?: string; branch: string; force?: boolean }, ) => Effect.Effect readonly checkoutRemoteBranch: ( repository: Repository, input: { remote?: string; branch: string; reset?: boolean }, ) => Effect.Effect readonly resetHard: (repository: Repository, revision: string) => Effect.Effect } readonly change: { readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect readonly apply: (input: { repository: Repository path: AbsolutePath changes: ChangeSet }) => Effect.Effect readonly discard: (input: { repository: Repository path: AbsolutePath index: "preserve" | "reset" untracked: "preserve" | "remove" }) => Effect.Effect } readonly worktree: { readonly create: (input: { repository: Repository directory: AbsolutePath }) => Effect.Effect readonly remove: (input: { repository: Repository directory: AbsolutePath force: boolean }) => Effect.Effect readonly list: (repository: Repository) => Effect.Effect } 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 ignored: (input: { repository: Repository paths: readonly RelativePath[] }) => Effect.Effect, OperationError> } readonly tree: { readonly capture: (input: { repository: Repository scopes: readonly RelativePath[] ignores?: Repository maximumUntrackedFileBytes?: number }) => Effect.Effect readonly write: (repository: Repository) => Effect.Effect readonly files: (input: { repository: Repository from: TreeID to: TreeID }) => Effect.Effect readonly diff: (input: { repository: Repository from: TreeID to: TreeID context?: number paths?: readonly RelativePath[] }) => Effect.Effect readonly preview: (input: { repository: Repository current: TreeID files: ReadonlyMap context?: number }) => Effect.Effect readonly restore: (input: { repository: Repository files: ReadonlyMap }) => Effect.Effect readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect } } export class Service extends Context.Service()("@opencode/GitV2") {} const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const proc = yield* AppProcess.Service const locks = KeyedMutex.makeUnsafe() const locked = (repository: Repository, effect: Effect.Effect) => 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 }, ) { 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() 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() 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 { 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 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 }) => 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) => // git diff --no-index returns 1 when differences were found. 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) }