import { assertEquals, assertRejects } from "@std/assert"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { localEnvironment } from "@berry/api/environment.ts"; import { testExtension } from "@berry/api/testing.ts"; import code from "./mod.ts"; import { onPath, tempDir } from "@berry/util"; const jjAvailable = onPath("jj"); async function jj(cwd: string, ...args: string[]): Promise { const result = await new Deno.Command("jj", { args: ["--no-pager", "--color=never", ...args], cwd, stdin: "null", stdout: "piped", stderr: "piped", }).output(); if (!result.success) throw new Error(new TextDecoder().decode(result.stderr)); return new TextDecoder().decode(result.stdout).trim(); } /** The commit that `dir`'s working copy sits on. */ const base = (dir: string) => jj(dir, "--ignore-working-copy", "log", "-r", "@-", "--no-graph", "-T", 'commit_id ++ "\n"'); async function project( run: (root: string, repo: string, workspaces: string) => Promise, ) { await using rootDir = await tempDir({ prefix: "workspace-test-" }); const root = rootDir.path; const saved = new Map( ["BERRY_WORKSPACES_DIR", "JJ_CONFIG"].map((key) => [key, Deno.env.get(key)]), ); try { const config = join(root, "config.toml"); await writeFile(config, '[user]\nname = "Test"\nemail = "test@example.com"\n'); Deno.env.set("JJ_CONFIG", config); const workspaces = join(root, "workspaces"); Deno.env.set("BERRY_WORKSPACES_DIR", workspaces); const repo = join(root, "repo with spaces"); await jj(root, "git", "init", repo); await writeFile(join(repo, "baseline.txt"), "shared base\n"); await jj(repo, "new"); await run(root, repo, workspaces); } finally { for (const [key, value] of saved) { if (value === undefined) Deno.env.delete(key); else Deno.env.set(key, value); } } } Deno.test({ name: "workspace skips empty ancestors and stays independent when the source staging commit changes", ignore: !jjAvailable, fn: () => project(async (_root, repo) => { const before = await base(repo); await jj(repo, "describe", "--message", "empty staging"); await jj(repo, "new", "--message", "another empty stage"); const ext = testExtension(code, { env: localEnvironment(repo) }); const target = await ext.command("workspace") as string; assertEquals(await base(target), before); await writeFile(join(repo, "baseline.txt"), "new source work\n"); await jj(repo, "status"); await jj(target, "status"); assertEquals(await readFile(join(target, "baseline.txt"), "utf8"), "shared base\n"); assertEquals(await base(target), before); }), }); Deno.test({ name: "workspace uses only the nearest nonempty ancestors of an empty merge", ignore: !jjAvailable, fn: () => project(async (_root, repo) => { await jj(repo, "new", "@-", "--message", "left"); await writeFile(join(repo, "left.txt"), "left\n"); const left = await jj(repo, "log", "-r", "@", "--no-graph", "-T", "commit_id"); await jj(repo, "new", "@-", "--message", "right"); await writeFile(join(repo, "right.txt"), "right\n"); const right = await jj(repo, "log", "-r", "@", "--no-graph", "-T", "commit_id"); await jj(repo, "new", left, right); const ext = testExtension(code, { env: localEnvironment(repo) }); const target = await ext.command("workspace") as string; const parents = await base(target); assertEquals(parents.split("\n").sort(), [left, right].sort()); assertEquals(await readFile(join(target, "left.txt"), "utf8"), "left\n"); assertEquals(await readFile(join(target, "right.txt"), "utf8"), "right\n"); }), }); Deno.test({ name: "workspace refuses a repository with no nonempty history before creating a destination", ignore: !jjAvailable, fn: () => project(async (root, _repo, workspaces) => { const empty = join(root, "empty"); await jj(root, "git", "init", empty); const ext = testExtension(code, { env: localEnvironment(empty) }); await assertRejects( () => ext.command("workspace"), Error, "no nonempty ancestor other than root", ); assertEquals(ext.env.cwd, empty); await assertRejects(() => Deno.stat(workspaces), Deno.errors.NotFound); }), }); Deno.test({ name: "workspace reuses its registered directory without resetting unfinished work", ignore: !jjAvailable, fn: () => project(async (root, repo) => { const ext = testExtension(code, { env: localEnvironment(repo) }); const target = await ext.command("workspace") as string; await writeFile(join(target, "unfinished.txt"), "keep me\n"); assertEquals(await ext.command("workspace"), target); ext.env.cwd = repo; await writeFile(join(repo, "later.txt"), "not part of the task\n"); Deno.env.set("BERRY_WORKSPACES_DIR", join(root, "different-location")); assertEquals(await ext.command("workspace"), target); assertEquals(await readFile(join(target, "unfinished.txt"), "utf8"), "keep me\n"); await assertRejects(() => readFile(join(target, "later.txt"))); }), }); Deno.test({ name: "workspace identifies the shared repo from another workspace even after the default is renamed", ignore: !jjAvailable, fn: () => project(async (_root, repo, workspaces) => { await jj(repo, "workspace", "rename", "main"); const first = testExtension(code, { tid: "first", env: localEnvironment(repo) }); const firstPath = await first.command("workspace") as string; const second = testExtension(code, { tid: "second", env: localEnvironment(firstPath), }); const secondPath = join(workspaces, "repo with spaces", "second"); assertEquals(await second.command("workspace"), secondPath); await writeFile(join(firstPath, "private.txt"), "first only\n"); await assertRejects(() => readFile(join(secondPath, "private.txt"))); assertEquals( (await jj(repo, "--ignore-working-copy", "workspace", "list", "-T", 'name ++ "\n"')) .split("\n").sort(), ["first", "main", "second"], ); }), }); Deno.test({ name: "workspace refuses an occupied destination and leaves both cwd and existing files alone", ignore: !jjAvailable, fn: () => project(async (_root, repo, workspaces) => { const tid = "occupied"; const target = join(workspaces, "repo with spaces", tid); await mkdir(target, { recursive: true }); await writeFile(join(target, "keep.txt"), "do not overwrite\n"); const ext = testExtension(code, { tid, env: localEnvironment(repo) }); await assertRejects(() => ext.command("workspace")); assertEquals(ext.env.cwd, repo); assertEquals(await readFile(join(target, "keep.txt"), "utf8"), "do not overwrite\n"); assertEquals( await jj(repo, "--ignore-working-copy", "workspace", "list", "-T", "name"), "default", ); }), }); Deno.test({ name: "workspace refuses non-jj directories, unexpected arguments, and unsafe conversation names without creating files or moving cwd", ignore: !jjAvailable, fn: () => project(async (root, repo, workspaces) => { const ext = testExtension(code, { env: localEnvironment(root) }); await assertRejects(() => ext.command("workspace"), Error, "No command /workspace"); assertEquals(ext.env.cwd, root); ext.env.cwd = repo; await assertRejects(() => ext.command("workspace", "extra"), Error, "Usage: /workspace"); assertEquals(ext.env.cwd, repo); const unsafe = testExtension(code, { tid: "../outside", env: localEnvironment(repo), }); await assertRejects(() => unsafe.command("workspace"), Error, "Invalid conversation ID"); assertEquals(unsafe.env.cwd, repo); await assertRejects(() => Deno.stat(workspaces), Deno.errors.NotFound); }), });