+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
988 lines
35 KiB
TypeScript
988 lines
35 KiB
TypeScript
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)
|
|
}
|