/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { Context, Effect, Layer, Schema } from "effect"; import { GitBranchName } from "./git.js"; const execFileP = promisify(execFile); // --------------------------------------------------------------------------- // Shell abstraction: local git runs as subprocesses, but the same contract can // be backed by an AgentOS VM exec so worktree ops work identically inside a VM. // --------------------------------------------------------------------------- export interface ShellExecOptions { readonly cwd?: string; readonly env?: Record; readonly timeoutMs?: number; } export interface ShellExecResult { readonly exitCode: number; readonly stderr: string; readonly stdout: string; } export interface Shell { readonly exec: ( command: string, args: readonly string[], options?: ShellExecOptions ) => Effect.Effect; } // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- export const GitLocalErrorReason = Schema.Literals([ "InvalidInput", "CommandFailed", "Timeout", "NotARepository", ]); export type GitLocalErrorReason = typeof GitLocalErrorReason.Type; export class GitLocalError extends Schema.TaggedErrorClass()( "GitLocalError", { message: Schema.String, reason: GitLocalErrorReason, } ) {} // --------------------------------------------------------------------------- // Inputs / outputs // --------------------------------------------------------------------------- export interface CloneInput { readonly into: string; readonly url: string; } export interface AddWorktreeInput { readonly branch: string; readonly path: string; readonly repositoryPath: string; } export interface CommitInput { readonly message: string; readonly repositoryPath: string; } export interface PushInput { readonly refspec: string; readonly repositoryPath: string; } export interface RemoteWithAuthInput { readonly host: string; readonly name?: string; readonly owner: string; readonly path: string; readonly repositoryPath: string; readonly token: string; readonly user: string; } // --------------------------------------------------------------------------- // Operations: each is a pure function of (Shell, input) -> Effect. // --------------------------------------------------------------------------- const git = ( shell: Shell, args: readonly string[], options?: ShellExecOptions ): Effect.Effect => shell.exec("git", args, options).pipe( Effect.flatMap((res) => res.exitCode === 0 ? Effect.succeed(res.stdout.trim()) : Effect.fail( new GitLocalError({ message: res.stderr.trim() || `git ${args.join(" ")} failed`, reason: /not a git repository|does not appear to be/iu.test( res.stderr ) ? "NotARepository" : "CommandFailed", }) ) ) ); export const clone = ( shell: Shell, input: CloneInput ): Effect.Effect => git(shell, ["clone", input.url, input.into]).pipe( Effect.map(() => input.into) ); export const addWorktree = ( shell: Shell, input: AddWorktreeInput ): Effect.Effect => Effect.gen(function* run() { yield* Schema.decodeUnknownEffect(GitBranchName)(input.branch).pipe( Effect.mapError( (cause) => new GitLocalError({ message: cause.message, reason: "InvalidInput" }) ) ); // -b creates the branch if it does not exist; -B would reset. Use -b. yield* git(shell, ["worktree", "add", "-b", input.branch, input.path], { cwd: input.repositoryPath, }); return input.path; }); export const commit = ( shell: Shell, input: CommitInput ): Effect.Effect => Effect.gen(function* run() { yield* git(shell, ["add", "-A"], { cwd: input.repositoryPath }); yield* git(shell, ["commit", "-m", input.message], { cwd: input.repositoryPath, }); return yield* git(shell, ["rev-parse", "HEAD"], { cwd: input.repositoryPath, }); }); export const push = ( shell: Shell, input: PushInput ): Effect.Effect => git(shell, ["push", "-u", "origin", input.refspec], { cwd: input.repositoryPath, }); export const currentBranch = ( shell: Shell, repositoryPath: string ): Effect.Effect => git(shell, ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repositoryPath, }); export const setRemoteUrl = ( shell: Shell, input: RemoteWithAuthInput ): Effect.Effect => { // Token-in-URL auth for push from a sandboxed checkout. The remote URL is // scoped to this single repo and the credential never leaves the worktree. const url = `https://${input.user}:${input.token}@${input.host}/${input.owner}/${input.path}`; const name = input.name ?? "origin"; return git(shell, ["remote", "set-url", name, url], { cwd: input.repositoryPath, }).pipe(Effect.map(() => url)); }; // --------------------------------------------------------------------------- // Default host shell (subprocess). The same Shell contract is implemented by // the AgentOS VM adapter so worktree ops run identically inside a VM. // --------------------------------------------------------------------------- // execFile resolves only on success; non-zero exits throw with stderr in the // message. We normalize that into a ShellExecResult so callers map exit codes. const runExecFile = async ( command: string, args: readonly string[], options?: ShellExecOptions ): Promise => { try { const { stdout, stderr } = await execFileP(command, [...args], { cwd: options?.cwd, env: options?.env, maxBuffer: 1024 * 1024 * 8, timeout: options?.timeoutMs, }); return { exitCode: 0, stderr, stdout }; } catch (error) { const failed = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; }; return { exitCode: 1, stderr: `${failed.stderr ?? ""}${failed.message}`, stdout: failed.stdout ?? "", }; } }; export const nodeShell: Shell = { exec: (command, args, options) => Effect.tryPromise({ catch: (cause) => new GitLocalError({ message: cause instanceof Error ? cause.message : "Spawn failed", reason: "CommandFailed", }), try: () => runExecFile(command, args, options), }), }; // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- interface GitLocalRuntimeShape { readonly shell: Shell; readonly clone: (input: CloneInput) => Effect.Effect; readonly addWorktree: ( input: AddWorktreeInput ) => Effect.Effect; readonly commit: (input: CommitInput) => Effect.Effect; readonly push: (input: PushInput) => Effect.Effect; readonly currentBranch: ( repositoryPath: string ) => Effect.Effect; readonly setRemoteUrl: ( input: RemoteWithAuthInput ) => Effect.Effect; } export class GitLocalRuntime extends Context.Service< GitLocalRuntime, GitLocalRuntimeShape >()("@code/primitives/git-local-runtime/GitLocalRuntime") {} export const makeGitLocalRuntimeLayer = (shell: Shell = nodeShell) => Layer.succeed( GitLocalRuntime, GitLocalRuntime.of({ addWorktree: (input) => addWorktree(shell, input), clone: (input) => clone(shell, input), commit: (input) => commit(shell, input), currentBranch: (repositoryPath) => currentBranch(shell, repositoryPath), push: (input) => push(shell, input), setRemoteUrl: (input) => setRemoteUrl(shell, input), shell, }) );