446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
export * as Git from "./git"
|
|
|
|
import path from "path"
|
|
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
|
import { ChildProcess } from "effect/unstable/process"
|
|
import { AbsolutePath } from "./schema"
|
|
import { FSUtil } from "./fs-util"
|
|
import { AppProcess } from "./process"
|
|
import { LayerNode } from "./effect/layer-node"
|
|
|
|
export interface Repo {
|
|
/**
|
|
* The root directory of the working tree that contains the input path.
|
|
*
|
|
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
|
|
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
|
|
* `/home/me/app-feature`.
|
|
*/
|
|
readonly directory: AbsolutePath
|
|
/**
|
|
* The shared Git storage directory used by this repo and any linked worktrees.
|
|
*
|
|
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
|
|
* For a linked worktree at `/home/me/app-feature` whose main checkout is
|
|
* `/home/me/app`, this is usually `/home/me/app/.git`.
|
|
*/
|
|
readonly store: AbsolutePath
|
|
}
|
|
|
|
export class 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 find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
|
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
|
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
|
readonly origin: (directory: string) => Effect.Effect<string | undefined>
|
|
readonly head: (directory: string) => Effect.Effect<string | undefined>
|
|
readonly dir: (directory: string) => Effect.Effect<string | undefined>
|
|
readonly branch: (directory: string) => Effect.Effect<string | undefined>
|
|
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
|
|
readonly clone: (input: {
|
|
remote: string
|
|
target: string
|
|
branch?: string
|
|
depth?: number
|
|
}) => Effect.Effect<Result, AppProcess.AppProcessError>
|
|
readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
|
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
|
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
|
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
|
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
|
|
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
|
|
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
|
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
|
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
|
readonly worktreeRemove: (input: {
|
|
repo: Repo
|
|
directory: AbsolutePath
|
|
force: boolean
|
|
}) => Effect.Effect<void, WorktreeError>
|
|
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const proc = yield* AppProcess.Service
|
|
|
|
const find = Effect.fn("Git.find")(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 commonDir = yield* git(["rev-parse", "--git-common-dir"])
|
|
if (commonDir.exitCode !== 0) return undefined
|
|
|
|
return {
|
|
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
|
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
|
} satisfies Repo
|
|
})
|
|
|
|
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
|
|
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
|
|
if (result.exitCode !== 0) return undefined
|
|
return result.text.trim() || undefined
|
|
})
|
|
|
|
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
|
|
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
|
if (result.exitCode !== 0) return []
|
|
return result.text
|
|
.split("\n")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
.toSorted()
|
|
})
|
|
|
|
const origin = Effect.fn("Git.origin")(function* (directory: string) {
|
|
const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"])
|
|
if (result.exitCode !== 0) return undefined
|
|
return result.text.trim() || undefined
|
|
})
|
|
|
|
const head = Effect.fn("Git.head")(function* (directory: string) {
|
|
const result = yield* run(directory, proc)(["rev-parse", "HEAD"])
|
|
if (result.exitCode !== 0) return undefined
|
|
return result.text.trim() || undefined
|
|
})
|
|
|
|
const dir = Effect.fn("Git.dir")(function* (directory: string) {
|
|
const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
|
|
if (result.exitCode !== 0) return undefined
|
|
return AbsolutePath.make(resolvePath(directory, result.text))
|
|
})
|
|
|
|
const branch = Effect.fn("Git.branch")(function* (directory: string) {
|
|
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
|
if (result.exitCode !== 0) return undefined
|
|
return result.text.trim() || undefined
|
|
})
|
|
|
|
const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) {
|
|
const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"])
|
|
if (result.exitCode !== 0) return undefined
|
|
return result.text.trim().replace(/^refs\/remotes\//, "") || undefined
|
|
})
|
|
|
|
const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) =>
|
|
execute(
|
|
path.dirname(input.target),
|
|
proc,
|
|
)([
|
|
"clone",
|
|
"--depth",
|
|
String(input.depth ?? 100),
|
|
...(input.branch ? ["--branch", input.branch] : []),
|
|
"--",
|
|
input.remote,
|
|
input.target,
|
|
]),
|
|
)
|
|
|
|
const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"]))
|
|
|
|
const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) =>
|
|
execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]),
|
|
)
|
|
|
|
const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
|
|
execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
|
|
)
|
|
|
|
const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
|
|
execute(directory, proc)(["reset", "--hard", target]),
|
|
)
|
|
|
|
const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
|
|
const root = yield* execute(
|
|
directory,
|
|
proc,
|
|
)(["rev-parse", "--show-toplevel"]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
|
)
|
|
if (root.exitCode !== 0) {
|
|
return yield* new PatchError({
|
|
operation: "capture",
|
|
directory,
|
|
message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
|
|
})
|
|
}
|
|
const repo = AbsolutePath.make(resolvePath(directory, root.text))
|
|
const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
|
|
const tracked = yield* execute(
|
|
repo,
|
|
proc,
|
|
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
|
)
|
|
if (tracked.exitCode !== 0) {
|
|
return yield* new PatchError({
|
|
operation: "capture",
|
|
directory,
|
|
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
|
})
|
|
}
|
|
|
|
const untracked = yield* execute(
|
|
repo,
|
|
proc,
|
|
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
|
)
|
|
if (untracked.exitCode !== 0) {
|
|
return yield* new PatchError({
|
|
operation: "capture",
|
|
directory,
|
|
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
|
})
|
|
}
|
|
|
|
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
|
execute(
|
|
repo,
|
|
proc,
|
|
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
|
Effect.mapError(
|
|
(cause) => new PatchError({ operation: "capture", directory, 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,
|
|
message:
|
|
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
return [tracked.text, ...created].filter(Boolean).join("\n")
|
|
})
|
|
|
|
const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
|
|
const result = yield* proc
|
|
.run(
|
|
ChildProcess.make("git", ["apply", "-"], {
|
|
cwd: input.directory,
|
|
extendEnv: true,
|
|
stdin: Stream.make(new TextEncoder().encode(input.patch)),
|
|
}),
|
|
)
|
|
.pipe(
|
|
Effect.mapError(
|
|
(cause) =>
|
|
new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
|
|
),
|
|
)
|
|
if (result.exitCode === 0) return
|
|
return yield* new PatchError({
|
|
operation: "apply",
|
|
directory: input.directory,
|
|
message:
|
|
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
|
})
|
|
})
|
|
|
|
const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
|
|
const reset = yield* execute(
|
|
directory,
|
|
proc,
|
|
)(["reset", "--hard", "HEAD"]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
|
)
|
|
if (reset.exitCode !== 0) {
|
|
return yield* new PatchError({
|
|
operation: "reset",
|
|
directory,
|
|
message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
|
|
})
|
|
}
|
|
const clean = yield* execute(
|
|
directory,
|
|
proc,
|
|
)(["clean", "-fd"]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
|
)
|
|
if (clean.exitCode === 0) return
|
|
return yield* new PatchError({
|
|
operation: "reset",
|
|
directory,
|
|
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
|
})
|
|
})
|
|
|
|
const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
|
|
const checkout = yield* execute(
|
|
directory,
|
|
proc,
|
|
)(["checkout", "--", "."]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
|
)
|
|
if (checkout.exitCode !== 0) {
|
|
return yield* new PatchError({
|
|
operation: "reset",
|
|
directory,
|
|
message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
|
|
})
|
|
}
|
|
const clean = yield* execute(
|
|
directory,
|
|
proc,
|
|
)(["clean", "-fd", "--", "."]).pipe(
|
|
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
|
)
|
|
if (clean.exitCode === 0) return
|
|
return yield* new PatchError({
|
|
operation: "reset",
|
|
directory,
|
|
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
|
})
|
|
})
|
|
|
|
const worktree = Effect.fnUntraced(function* (
|
|
operation: "create" | "remove" | "list",
|
|
repo: Repo,
|
|
args: string[],
|
|
worktreeDirectory?: AbsolutePath,
|
|
cwd = repo.directory,
|
|
) {
|
|
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.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
|
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
|
|
})
|
|
|
|
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
|
|
repo: Repo
|
|
directory: AbsolutePath
|
|
force: boolean
|
|
}) {
|
|
yield* worktree(
|
|
"remove",
|
|
input.repo,
|
|
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
|
|
input.directory,
|
|
input.repo.store,
|
|
)
|
|
})
|
|
|
|
const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
|
|
return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("worktree "))
|
|
.map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
|
|
})
|
|
|
|
return Service.of({
|
|
find,
|
|
remote,
|
|
roots,
|
|
origin,
|
|
head,
|
|
dir,
|
|
branch,
|
|
remoteHead,
|
|
clone,
|
|
fetch,
|
|
fetchBranch,
|
|
checkout,
|
|
reset,
|
|
patch,
|
|
applyPatch,
|
|
resetChanges,
|
|
softResetChanges,
|
|
worktreeCreate,
|
|
worktreeRemove,
|
|
worktreeList,
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
|
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
|
|
|
|
export interface Result {
|
|
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)
|
|
}
|