Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export type {
export {
type GitWorkspaceOptions,
gitWorkspace,
jjWorkspace,
localShell,
type Shell,
type Workspace,
Expand Down
49 changes: 49 additions & 0 deletions src/runtime/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
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)
}
44 changes: 43 additions & 1 deletion tests/loops/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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')
})
})
Loading