| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987 |
- 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: {
- /** 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<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) =>
- // 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)
- }
|