import type { Context } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { getOrThrow } from "@earendil-works/pi-durable/env"; import type { Environment } from "@berry/api"; async function jj(env: Environment, cwd: string, args: string[], context: Context) { const streams = { stdout: "", stderr: "" }; const result = getOrThrow( await env.exec(["jj", "--no-pager", "--color=never", ...args], { cwd, onOutput: (text, _, { stream }) => void (streams[stream] += text), }, context), ); if (result.exitCode !== 0) throw new Error(streams.stderr.trim()); return streams.stdout.trim(); } /** On either kind of machine: `/` on unix, a drive or a share on windows. */ const isAbsolute = (path: string) => /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path); /** The shared repository directory, regardless of which workspace names it. */ async function repository(env: Environment, root: string, context: Context): Promise { const path = getOrThrow(await env.joinPath([root, ".jj", "repo"], context)); if (getOrThrow(await env.fileInfo(path, context)).kind === "directory") { return getOrThrow(await env.canonicalPath(path, context)); } // a secondary workspace's names the repository, relative to its .jj const named = getOrThrow(await env.readTextFile(path, context)).trim(); const resolved = isAbsolute(named) ? named : getOrThrow(await env.joinPath([root, ".jj", named], context)); return getOrThrow(await env.canonicalPath(resolved, context)); } /** The last component of a path, on either kind of machine. */ const basename = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).at(-1) ?? ""; /** Whether the working directory belongs to a reachable jj workspace. */ export async function hasWorkspace(env: Environment): Promise { try { await jj(env, env.cwd, ["--ignore-working-copy", "workspace", "root"], BACKGROUND_CONTEXT); return true; } catch { return false; } } /** Create or reuse a conversation's workspace without rewriting existing work. */ export async function workspace(env: Environment, name: string, context: Context): Promise { if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error("Invalid conversation ID for a workspace."); let source: string; try { source = await jj(env, env.cwd, ["--ignore-working-copy", "workspace", "root"], context); } catch (error) { throw new Error(`Cannot create a workspace: cwd must be in a jj repository.\n${error}`); } const repo = await repository(env, source, context); const names = await jj(env, source, [ "--ignore-working-copy", "workspace", "list", "--template", 'name ++ "\n"', ], context); if (names.split("\n").includes(name)) { const target = getOrThrow( await env.canonicalPath( await jj( env, source, ["--ignore-working-copy", "workspace", "root", "--name", name], context, ), context, ), ); const root = await jj(env, target, ["--ignore-working-copy", "workspace", "root"], context); if ( getOrThrow(await env.canonicalPath(root, context)) !== target || await repository(env, target, context) !== repo ) { throw new Error(`Workspace ${name} no longer belongs to this repository: ${target}`); } return target; } const base = env.variables.BERRY_WORKSPACES_DIR ?? "/x/workspaces"; if (!isAbsolute(base)) throw new Error("BERRY_WORKSPACES_DIR must be an absolute path."); // the repository is /.jj/repo const project = getOrThrow(await env.joinPath([repo, "..", ".."], context)); const parent = getOrThrow(await env.joinPath([base, basename(project)], context)); const target = getOrThrow(await env.joinPath([parent, name], context)); // empty working-copy commits are mutable staging areas, not shared task bases. const parents = await jj(env, source, [ "log", "--no-graph", "--revisions", "heads(ancestors(@-) & ~empty() & ~root())", "--template", 'commit_id ++ "\n"', ], context); if (!parents) throw new Error("Cannot create a workspace: no nonempty ancestor other than root."); getOrThrow(await env.createDir(parent, { recursive: true }, context)); await jj(env, source, [ "workspace", "add", "--name", name, ...parents.split("\n").flatMap((parent) => ["--revision", parent]), target, ], context); return getOrThrow(await env.canonicalPath(target, context)); }