From f88e2bd4e5eff577ec5480c30e9f07b111a87eca Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 8 Jun 2026 08:39:31 -0600 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20add=20jjWorkspace=20=E2=80=94?= =?UTF-8?q?=20jj/Jujutsu=20backend=20for=20the=20Workspace=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Workspace port abstracts the durable shared store behind a Shell; this adds a jj-backed implementation alongside gitWorkspace, returning the same WorkspaceCommit discriminated union (drop-in). jj suits accumulating agent loops: no staging area, a first-class operation log (native resume/undo), and conflicts recorded rather than aborted — a rejected push surfaces as a typed blocker. Author identity is injected via --config-toml so a throwaway colocated clone is self-contained (parallels gitWorkspace's -c user.*). Exported via /loops; covered by a test mirroring the gitWorkspace contract that skips when the jj binary is absent (CI). --- src/runtime/index.ts | 1 + src/runtime/workspace.ts | 49 +++++++++++++++++++++++++++++++++++ tests/loops/workspace.test.ts | 44 ++++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/runtime/index.ts b/src/runtime/index.ts index e3733d5..00e7f3c 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -259,6 +259,7 @@ export type { export { type GitWorkspaceOptions, gitWorkspace, + jjWorkspace, localShell, type Shell, type Workspace, diff --git a/src/runtime/workspace.ts b/src/runtime/workspace.ts index 22f1cb8..3246a46 100644 --- a/src/runtime/workspace.ts +++ b/src/runtime/workspace.ts @@ -83,6 +83,55 @@ export function gitWorkspace(opts: GitWorkspaceOptions): Workspace { } } +/** A jj-backed `Workspace` (Jujutsu, colocated with git for the durable remote). + * Same port, same `Shell` — a drop-in for `gitWorkspace`. jj suits agent loops: + * no staging area, and a first-class operation log (native resume/undo). Live use + * requires `jj` on the `Shell`'s host. */ +export function jjWorkspace(opts: GitWorkspaceOptions): Workspace { + const shell = opts.shell ?? localShell() + const branch = opts.branch ?? 'main' + // jj reads its author identity from config, not per-call flags like git's `-c + // user.*`; inject it via --config-toml so a throwaway clone is self-contained + // (parallels gitWorkspace's `ident`). Global flags must precede the subcommand. + const ident = [ + '--config-toml', + 'user.name="workspace"', + '--config-toml', + 'user.email="workspace@tangle.local"', + ] + + const jj = async (args: string[], cwd?: string): Promise => { + const res = await shell(['jj', ...ident, ...args], cwd) + if (res.code !== 0) { + throw new Error( + `jj ${args.join(' ')} failed (${res.code}): ${tail(res.stderr || res.stdout)}`, + ) + } + return res.stdout + } + + return { + ref: opts.ref, + // Colocated clone: jj manages history, git holds the durable remote. + materialize: (dir) => jj(['git', 'clone', '--colocate', opts.ref, dir]).then(() => {}), + async commit(dir, message) { + // jj auto-snapshots the working copy; describe the change, then open a fresh + // empty change so the next commit doesn't amend this one. A rejected push is + // surfaced as a typed blocker (conflicts are first-class in jj, not aborted). + await jj(['describe', '-m', message], dir) + await jj(['new'], dir) + const push = await shell(['jj', ...ident, 'git', 'push', '--branch', branch], dir) + if (push.code !== 0) return { ok: false, conflict: tail(push.stderr || push.stdout) } + const rev = (await jj(['log', '--no-graph', '-r', '@-', '-T', 'commit_id'], dir)).trim() + return { ok: true, rev } + }, + async head() { + const out = await shell(['git', 'ls-remote', opts.ref, `refs/heads/${branch}`]) + return out.stdout.split(/\s+/)[0] ?? '' + }, + } +} + function tail(s: string): string { return s.slice(-400) } diff --git a/tests/loops/workspace.test.ts b/tests/loops/workspace.test.ts index 113d9d1..38b4054 100644 --- a/tests/loops/workspace.test.ts +++ b/tests/loops/workspace.test.ts @@ -3,7 +3,17 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { gitWorkspace } from '../../src/runtime/workspace' +import { gitWorkspace, jjWorkspace } from '../../src/runtime/workspace' + +/** jj is optional and absent in CI — its block skips unless the binary is present. */ +const hasJj = (() => { + try { + execFileSync('jj', ['--version'], { stdio: 'pipe' }) + return true + } catch { + return false + } +})() const git = (args: string[], cwd?: string): string => execFileSync( @@ -69,3 +79,35 @@ describe('gitWorkspace', () => { expect(await ws.commit(w2, 'w2')).toMatchObject({ ok: false }) }) }) + +describe.skipIf(!hasJj)('jjWorkspace', () => { + let bare: string + const temps: string[] = [] + const fresh = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'ws-jj-work-')) + temps.push(dir) + return dir + } + + beforeEach(() => { + bare = seedBare() + }) + + afterEach(() => { + rmSync(bare, { recursive: true, force: true }) + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + it('carries durable state across fresh worker filesystems (jj drop-in)', async () => { + const ws = jjWorkspace({ ref: bare }) + const w1 = fresh() + await ws.materialize(w1) + writeFileSync(join(w1, 'a.txt'), 'one\n') + expect(await ws.commit(w1, 'add a')).toMatchObject({ ok: true }) + + const w2 = fresh() + await ws.materialize(w2) + expect(existsSync(join(w2, 'a.txt'))).toBe(true) + expect(readFileSync(join(w2, 'a.txt'), 'utf-8')).toBe('one\n') + }) +})