Files
deepseek-harness/packages/experimental/worktree/tests/worktree.spec.ts
T
Yichen Jiangandimccyu e2c4d0538f ci: make reference and worktree tests portable (#7381)
* fix(forge): make reference and worktree tests portable

* test(worktree): cover error and text cancellation reasons

* test(forge): brace void arrow bodies in the reference lookup spec

* test(ci): expect the joined version script path in the pre-merge check

---------

Co-authored-by: imccyu <[email protected]>
2026-10-27 02:20:02 +08:00

575 lines
28 KiB
TypeScript

import { existsSync } from 'node:fs'
import { access, mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { join, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import WorktreeService from '../src/index.ts'
import type { RestoreWorktreeRequest } from '../src/index.ts'
import { contents, git, harness, repository, testAgent } from './harness.ts'
let ctx: Context | undefined
const roots: string[] = []
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function repo(): Promise<string> {
const root = await repository()
roots.push(root)
return root
}
describe('creating and entering retained worktrees', () => {
it('pins HEAD, preserves source edits and index, enters the new branch, and ignores its new pool', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const baseCommit = await git(root, 'rev-parse', 'HEAD')
await writeFile(join(root, 'tracked.txt'), 'staged\n')
await git(root, 'add', 'tracked.txt')
await writeFile(join(root, 'tracked.txt'), 'unstaged\n')
await writeFile(join(root, 'untracked.txt'), 'local\n')
const before = await git(root, 'status', '--porcelain')
const result = await ctx.worktrees.create(agent, { name: 'topic' })
expect(result).toEqual({ path: join(root, '.agents/worktrees/topic'), branch: 'topic', baseCommit, repositoryRoot: root })
expect(ctx.workingDirectory.get(agent.session)).toBe(result.path)
expect(await git(result.path, 'symbolic-ref', '--short', 'HEAD')).toBe('topic')
expect(await contents(join(result.path, 'tracked.txt'))).toBe('initial\n')
expect(await git(root, 'show', ':tracked.txt')).toBe('staged')
expect(await contents(join(root, 'tracked.txt'))).toBe('unstaged\n')
expect(await git(root, 'status', '--porcelain')).toBe(before)
expect(await contents(join(root, '.agents/worktrees/.gitignore'))).toBe('*\n')
expect(await git(root, 'check-ignore', '.agents/worktrees/.gitignore')).toBe('.agents/worktrees/.gitignore')
await ctx.workingDirectory.set(agent, root)
expect(await git(root, 'rev-parse', 'topic')).toBe(baseCommit)
expect(await contents(join(result.path, 'tracked.txt'))).toBe('initial\n')
})
it('reports the prepare stage, then the checkout stage, before each begins', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const phases: string[] = []
const result = await ctx.worktrees.create(agent, { name: 'staged' }, undefined, (phase) => {
phases.push(`${phase}:${String(ctx?.workingDirectory.get(agent.session) === root)}`)
})
expect(phases).toEqual(['prepare:true', 'checkout:true'])
expect(ctx.workingDirectory.get(agent.session)).toBe(result.path)
})
it('uses current cwd for repository discovery and accepts an annotated local tag', async () => {
const original = await repo()
const root = await repo()
await mkdir(join(root, 'nested'))
await git(root, 'tag', '-a', 'baseline', '-m', 'baseline')
const baseCommit = await git(root, 'rev-parse', 'HEAD')
await writeFile(join(root, 'tracked.txt'), 'later\n')
await git(root, 'commit', '-am', 'later')
ctx = await harness(original)
const agent = testAgent(ctx, original)
await ctx.workingDirectory.set(agent, join(root, 'nested'))
const result = await ctx.worktrees.create(agent, { name: 'topic/nested', from: 'baseline' })
expect(result).toEqual({ path: join(root, '.agents/worktrees/topic/nested'), branch: 'topic/nested', baseCommit, repositoryRoot: root })
expect(await contents(join(result.path, 'tracked.txt'))).toBe('initial\n')
expect(await git(original, 'branch', '--list', 'topic/nested')).toBe('')
})
it('keeps existing pool files and honors configured location and generated-name prefix', async () => {
const root = await repo()
await mkdir(join(root, 'checkouts'))
await writeFile(join(root, 'checkouts/.gitignore'), 'custom\n')
ctx = await harness(root, { directory: 'checkouts', namePrefix: 'task-' })
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent)
expect(created.branch).toMatch(/^task-[0-9a-f]{8}$/)
expect(created.path).toBe(join(root, 'checkouts', created.branch))
expect(await contents(join(root, 'checkouts/.gitignore'))).toBe('custom\n')
})
it('names a generated worktree after the UUID in the Session id', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root, 'session-6B1F02C4-61de-44ee-813f-1191894bc2c5')
const created = await ctx.worktrees.create(agent)
expect(created.branch).toMatch(/^dsh-6b1f02c4-[0-9a-f]{4}$/)
expect(created.path).toBe(join(root, '.agents/worktrees', created.branch))
})
it('creates from a linked checkout using its own commit and default pool', async () => {
const root = await repo()
const linked = join(root, 'linked')
await git(root, 'worktree', 'add', '-b', 'linked', linked)
await writeFile(join(linked, 'tracked.txt'), 'linked revision\n')
await git(linked, 'commit', '-am', 'linked revision')
const baseCommit = await git(linked, 'rev-parse', 'HEAD')
ctx = await harness(linked)
const agent = testAgent(ctx, linked)
const result = await ctx.worktrees.create(agent, { name: 'from-linked' })
expect(result).toEqual({
path: join(linked, '.agents/worktrees/from-linked'),
branch: 'from-linked', baseCommit, repositoryRoot: linked,
})
expect(await contents(join(result.path, 'tracked.txt'))).toBe('linked revision\n')
expect(await contents(join(root, 'tracked.txt'))).toBe('initial\n')
expect(await git(result.path, 'symbolic-ref', '--short', 'HEAD')).toBe('from-linked')
expect(await git(linked, 'symbolic-ref', '--short', 'HEAD')).toBe('linked')
expect(await git(root, 'worktree', 'list', '--porcelain')).toContain(`worktree ${result.path.split(sep).join('/')}`)
expect(ctx.workingDirectory.get(agent.session)).toBe(result.path)
})
it.each(['preflight', 'allocation'] as const)('refuses a regular file at the pool path during %s without replacing it or creating a branch', async (stage) => {
const root = await repo()
const pool = join(root, 'checkouts')
ctx = await harness(root, { directory: 'checkouts' })
const agent = testAgent(ctx, root)
if (stage === 'preflight') {
await writeFile(pool, 'retain this file\n')
} else {
const inspect = ctx.fs.lstat.bind(ctx.fs)
vi.spyOn(ctx.fs, 'lstat').mockImplementationOnce(async (...args) => {
const found = await inspect(...args)
await writeFile(pool, 'retain this file\n')
return found
})
}
await expect(ctx.worktrees.create(agent, { name: 'blocked' })).rejects.toThrow(stage === 'preflight'
? 'a parent path segment is not a directory' : 'EEXIST')
expect(await contents(pool)).toBe('retain this file\n')
expect(await git(root, 'branch', '--list', 'blocked')).toBe('')
expect((await git(root, 'worktree', 'list', '--porcelain')).split('\n').filter(line => line.startsWith('worktree ')))
.toEqual([`worktree ${root.split(sep).join('/')}`])
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
})
it('keeps the resolved commit when HEAD advances before checkout allocation', async () => {
const root = await repo()
const baseCommit = await git(root, 'rev-parse', 'HEAD')
ctx = await harness(root)
const original = ctx.fs.lstat.bind(ctx.fs)
vi.spyOn(ctx.fs, 'lstat').mockImplementationOnce(async (...args) => {
const value = await original(...args)
await writeFile(join(root, 'tracked.txt'), 'advanced\n')
await git(root, 'commit', '-am', 'advance')
return value
})
const result = await ctx.worktrees.create(testAgent(ctx, root), { name: 'pinned' })
expect(result.baseCommit).toBe(baseCommit)
expect(await git(root, 'rev-parse', 'HEAD')).not.toBe(baseCommit)
expect(await contents(join(result.path, 'tracked.txt'))).toBe('initial\n')
})
it('refuses missing partial-clone objects without fetching from the available local remote', async () => {
const source = await repo()
await git(source, 'config', 'uploadpack.allowFilter', 'true')
const partial = join(source, 'partial')
await git(source, 'clone', '--filter=blob:none', '--no-checkout', pathToFileURL(source).href, partial)
const missing = await git(partial, 'rev-list', '--objects', '--missing=print', 'HEAD')
expect(missing.split('\n').filter(line => line.startsWith('?'))).toHaveLength(1)
ctx = await harness(partial)
const agent = testAgent(ctx, partial)
await expect(ctx.worktrees.create(agent, { name: 'local-only' })).rejects.toThrow('worktree command failed')
expect(await git(partial, 'rev-list', '--objects', '--missing=print', 'HEAD')).toBe(missing)
expect(ctx.workingDirectory.get(agent.session)).toBe(partial)
})
it('aborts pending commands and waits for their settlement before service disposal completes', async () => {
const root = await repo()
ctx = await harness(root)
const service = ctx.worktrees
const [fiber] = [...ctx.registry.get(WorktreeService)!.fibers]
const entered = Promise.withResolvers<AbortSignal>()
const aborted = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const resolveExecutable = ctx.subprocess.resolveExecutable.bind(ctx.subprocess)
const probe = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementationOnce(async (command, env, signal) => {
if (signal === undefined) throw new Error('worktree commands require cancellation')
signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
entered.resolve(signal)
await release.promise
signal.throwIfAborted()
return resolveExecutable(command, env, signal)
})
const operation = service.create(testAgent(ctx, root), { name: 'cancelled' })
const rejection = expect(operation).rejects.toThrow('worktree service disposed')
let disposal: Promise<void> | undefined
try {
const signal = await entered.promise
let disposed = false
disposal = fiber!.dispose().then(() => { disposed = true })
expect(await Promise.race([
aborted.promise.then(() => 'aborted'),
disposal.then(() => 'disposed'),
])).toBe('aborted')
expect(signal.aborted).toBe(true)
expect(disposed).toBe(false)
release.resolve(undefined)
await rejection
await disposal
expect(disposed).toBe(true)
expect(await readdir(root)).toEqual(['.git', 'tracked.txt'])
await expect(service.create(testAgent(ctx, root, 'after-disposal'))).rejects.toThrow('worktree service disposed')
} finally {
release.resolve(undefined)
await Promise.allSettled([operation, rejection, disposal])
probe.mockRestore()
}
})
it('claims one checkout when concurrent callers choose the same new name', async () => {
const root = await repo()
ctx = await harness(root)
const first = testAgent(ctx, root, 'first')
const second = testAgent(ctx, root, 'second')
const original = ctx.fs.lstat.bind(ctx.fs)
let observed = 0
let release!: () => void
const bothAbsent = new Promise<void>((resolve) => { release = resolve })
vi.spyOn(ctx.fs, 'lstat').mockImplementation(async (...args) => {
const value = await original(...args)
observed++
if (observed === 2) release()
await bothAbsent
return value
})
const results = await Promise.allSettled([
ctx.worktrees.create(first, { name: 'shared' }),
ctx.worktrees.create(second, { name: 'shared' }),
])
expect(results.map(result => result.status).sort()).toEqual(['fulfilled', 'rejected'])
expect([ctx.workingDirectory.get(first.session), ctx.workingDirectory.get(second.session)].sort())
.toEqual([root, join(root, '.agents/worktrees/shared')].sort())
expect(await contents(join(root, '.agents/worktrees/shared/tracked.txt'))).toBe('initial\n')
})
it('leaves an existing pool without a gitignore unchanged', async () => {
const root = await repo()
await mkdir(join(root, '.agents/worktrees'), { recursive: true })
ctx = await harness(root)
await ctx.worktrees.create(testAgent(ctx, root), { name: 'topic' })
expect(await readdir(join(root, '.agents/worktrees'))).toEqual(['topic'])
})
it('refuses branch or destination reuse and invalid local revisions before changing cwd', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
await git(root, 'branch', 'existing')
await mkdir(join(root, '.agents/worktrees/occupied'), { recursive: true })
await expect(ctx.worktrees.create(agent, { name: 'existing' })).rejects.toThrow('branch already exists')
await expect(ctx.worktrees.create(agent, { name: 'occupied' })).rejects.toThrow('path already exists')
await expect(ctx.worktrees.create(agent, { name: 'missing', from: 'unavailable' })).rejects.toThrow('command failed')
await expect(ctx.worktrees.create(agent, { name: '../escape' })).rejects.toThrow('relative Git branch')
await expect(ctx.worktrees.create(agent, { name: 'invalid name' })).rejects.toThrow('command failed')
await expect(ctx.worktrees.create(agent, { from: '' })).rejects.toThrow('from must name')
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
expect(await git(root, 'worktree', 'list', '--porcelain')).not.toContain('/occupied')
expect(await readdir(join(root, '.agents/worktrees'))).toEqual(['occupied'])
})
it('retains the checkout and branch if publishing the new cwd fails', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
await ctx.workingDirectory.ensure(agent)
vi.spyOn(ctx.workingDirectory, 'set').mockRejectedValueOnce(new Error('directory publication failed'))
await expect(ctx.worktrees.create(agent, { name: 'retained' })).rejects.toThrow('Any created checkout and branch are retained')
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
expect(await git(root, 'branch', '--list', 'retained')).toContain('retained')
expect(await contents(join(root, '.agents/worktrees/retained/tracked.txt'))).toBe('initial\n')
})
it('does not spawn Git when the sandbox refuses the standing workspace-write policy', async () => {
const root = await repo()
ctx = await harness(root, {}, 'workspace-write')
const agent = testAgent(ctx, root)
const failure = new Error('fixture sandbox refused confinement')
const confine = vi.spyOn(ctx.sandbox, 'confine').mockRejectedValue(failure)
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
await expect(ctx.worktrees.create(agent, { name: 'denied-confinement' })).rejects.toBe(failure)
expect(confine).toHaveBeenCalledOnce()
expect(confine.mock.calls[0]?.[1]).toMatchObject({ mode: 'workspace-write', workspaceRoot: root })
expect(spawn).not.toHaveBeenCalled()
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
expect(await readdir(root)).toEqual(['.git', 'tracked.txt'])
})
it('does not reset the dirty source when shared Git configuration points at it', async () => {
const root = await repo()
await git(root, 'config', 'core.worktree', root)
await writeFile(join(root, 'tracked.txt'), 'dirty source\n')
const config = await contents(join(root, '.git/config'))
ctx = await harness(root)
const result = await ctx.worktrees.create(testAgent(ctx, root), { name: 'configured' })
expect(await contents(join(root, 'tracked.txt'))).toBe('dirty source\n')
expect(await contents(join(root, '.git/config'))).toBe(config)
expect(await contents(join(result.path, 'tracked.txt'))).toBe('initial\n')
expect(await git(result.path, 'rev-parse', '--show-toplevel')).toBe(result.path.split(sep).join('/'))
})
it('rejects cancellation and invalid deployment config before allocating a checkout', async () => {
const root = await repo()
for (const config of [{ directory: '../escape' }, { namePrefix: '-bad' }, { gitCommand: '' }, { timeoutMs: 0 }]) {
await expect(harness(root, config)).rejects.toThrow('worktree:')
}
ctx = await harness(root)
await expect(ctx.worktrees.create(testAgent(ctx, root), {}, AbortSignal.abort(new Error('cancelled')))).rejects.toThrow('cancelled')
expect(await readdir(root)).toEqual(['.git', 'tracked.txt'])
})
it('refuses truncated Git metadata before allocating a checkout', async () => {
const root = await repo()
ctx = await harness(root, { maxOutputBytes: 1 })
const agent = testAgent(ctx, root)
await expect(ctx.worktrees.create(agent, { name: 'bounded' })).rejects.toThrow('output exceeds maxOutputBytes')
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
expect(await readdir(root)).toEqual(['.git', 'tracked.txt'])
})
it('retains a caller cancellation reason when allocation is cancelled before its child starts', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const controller = new AbortController()
const resolveExecutable = ctx.subprocess.resolveExecutable.bind(ctx.subprocess)
const probe = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, env, signal) => {
if (command === 'node') controller.abort('caller stopped')
return resolveExecutable(command, env, signal)
})
try {
await expect(ctx.worktrees.create(agent, { name: 'cancelled-allocation' }, controller.signal)).rejects.toThrow('caller stopped')
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
expect(await readdir(root)).toEqual(['.git', 'tracked.txt'])
} finally {
probe.mockRestore()
}
})
})
describe('restoring removed worktrees', () => {
/** The checkout directories Git registers for `root`, in Git's notation. */
async function registered(root: string): Promise<string[]> {
return (await git(root, 'worktree', 'list', '--porcelain')).split('\n')
.filter(line => line.startsWith('worktree ')).map(line => line.slice('worktree '.length))
}
/** `path` in the notation Git prints. */
function gitPath(path: string): string {
return path.split(sep).join('/')
}
/** Commit one new file on the branch checked out at `checkout`. */
async function commitIn(checkout: string, file: string): Promise<string> {
await writeFile(join(checkout, file), `${file}\n`)
await git(checkout, 'add', file)
await git(checkout, 'commit', '-m', file)
return await git(checkout, 'rev-parse', 'HEAD')
}
it('checks the branch out again at its latest commit after removing only its stale registration, without entering the checkout', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
const tip = await commitIn(created.path, 'later.txt')
const abandoned = await ctx.worktrees.create(testAgent(ctx, root, 'abandoned'), { name: 'abandoned' })
await ctx.workingDirectory.set(agent, root)
await rm(created.path, { recursive: true, force: true })
await rm(abandoned.path, { recursive: true, force: true })
const phases: string[] = []
const restored = await ctx.worktrees.restore(agent, created, undefined, (phase) => {
phases.push(`${phase}:${String(existsSync(created.path))}`)
})
expect(phases).toEqual(['prepare:false', 'checkout:true'])
expect(restored).toEqual({ path: created.path, branchRecreated: false })
expect(await git(created.path, 'symbolic-ref', '--short', 'HEAD')).toBe('topic')
expect(await git(created.path, 'rev-parse', 'HEAD')).toBe(tip)
expect(await contents(join(created.path, 'later.txt'))).toBe('later.txt\n')
expect(await git(created.path, 'status', '--porcelain')).toBe('')
expect(await registered(root)).toEqual([gitPath(root), gitPath(abandoned.path), gitPath(created.path)])
expect(await git(root, 'branch', '--list', 'abandoned')).toContain('abandoned')
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
})
it('creates a deleted branch again at its base commit, without the commits it had gained', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
await commitIn(created.path, 'later.txt')
await commitIn(root, 'advanced.txt')
await git(root, 'worktree', 'remove', '--force', created.path)
await git(root, 'branch', '-D', 'topic')
const restored = await ctx.worktrees.restore(agent, created)
expect(restored).toEqual({ path: created.path, branchRecreated: true })
expect(await git(root, 'rev-parse', 'topic')).toBe(created.baseCommit)
expect(await git(created.path, 'symbolic-ref', '--short', 'HEAD')).toBe('topic')
expect(await contents(join(created.path, 'tracked.txt'))).toBe('initial\n')
await expect(access(join(created.path, 'later.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
await expect(access(join(created.path, 'advanced.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(ctx.workingDirectory.get(agent.session)).toBe(created.path)
})
it('refuses a directory that exists before running any command', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
await expect(ctx.worktrees.restore(agent, created)).rejects.toThrow(`worktree path already exists: ${created.path}`)
expect(spawn).not.toHaveBeenCalled()
})
it('rejects an unavailable recorded base before allocating files or removing registrations', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const path = join(root, '.agents/worktrees/vanished')
const request: RestoreWorktreeRequest = { path, branch: 'vanished', baseCommit: 'f'.repeat(40), repositoryRoot: root }
await expect(ctx.worktrees.restore(agent, request)).rejects.toThrow('worktree command failed')
await expect(access(path)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await git(root, 'branch', '--list', 'vanished')).toBe('')
expect(ctx.workingDirectory.get(agent.session)).toBe(root)
})
it.each([new Error('caller stopped'), 'caller stopped'])('retains cancellation reason %s when restoration is cancelled before its allocation starts', async (reason) => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
await rm(created.path, { recursive: true, force: true })
const controller = new AbortController()
const resolveExecutable = ctx.subprocess.resolveExecutable.bind(ctx.subprocess)
const probe = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, env, signal) => {
if (command === 'node') controller.abort(reason)
return resolveExecutable(command, env, signal)
})
try {
await expect(ctx.worktrees.restore(agent, created, controller.signal))
.rejects.toThrow(`Could not restore worktree topic at ${created.path}. Any created checkout and branch are retained. caller stopped`)
await expect(access(created.path)).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
probe.mockRestore()
}
})
it('rejects a cancelled restoration, and every restoration after service disposal, before running any command', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const request: RestoreWorktreeRequest = {
path: join(root, '.agents/worktrees/topic'), branch: 'topic', baseCommit: await git(root, 'rev-parse', 'HEAD'), repositoryRoot: root,
}
const service = ctx.worktrees
const [fiber] = [...ctx.registry.get(WorktreeService)!.fibers]
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
await expect(service.restore(agent, request, AbortSignal.abort(new Error('cancelled')))).rejects.toThrow('cancelled')
await fiber!.dispose()
await expect(service.restore(agent, request)).rejects.toThrow('worktree service disposed')
expect(spawn).not.toHaveBeenCalled()
expect(await readdir(root)).toEqual(['.git', 'tracked.txt'])
})
})
describe('restoration validation', () => {
it.each(['outside', 'traversal', 'branch', 'unsafe-branch', 'commit', 'root', 'nested-root'] as const)('rejects invalid %s before mutating the repository', async (kind) => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
await rm(created.path, { recursive: true, force: true })
const before = await git(root, 'worktree', 'list', '--porcelain')
const request = { ...created }
if (kind === 'outside') request.path = join(root, 'outside')
if (kind === 'traversal') request.path = `${root}/.agents/worktrees/../../outside`
if (kind === 'branch') request.branch = 'bad name'
if (kind === 'unsafe-branch') request.branch = '../topic'
if (kind === 'commit') request.baseCommit = 'HEAD'
if (kind === 'root') request.repositoryRoot = join(root, '.agents')
if (kind === 'nested-root') {
request.repositoryRoot = join(root, '.agents')
request.path = join(request.repositoryRoot, '.agents/worktrees/topic')
}
await expect(ctx.worktrees.restore(agent, request)).rejects.toThrow()
expect(await git(root, 'worktree', 'list', '--porcelain')).toBe(before)
await expect(access(created.path)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(access(join(root, 'outside'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it('preserves a locked missing worktree registration', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
await git(root, 'worktree', 'lock', created.path)
await rm(created.path, { recursive: true, force: true })
const before = await git(root, 'worktree', 'list', '--porcelain')
await expect(ctx.worktrees.restore(agent, created)).rejects.toThrow('worktree is locked')
expect(await git(root, 'worktree', 'list', '--porcelain')).toBe(before)
})
})
it('rejects a restore path redirected outside the pool by a symlink', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
await rm(created.path, { recursive: true, force: true })
const outside = join(root, 'outside')
const link = join(root, '.agents/worktrees/redirect')
await mkdir(outside)
await symlink(outside, link, 'junction')
const before = await git(root, 'worktree', 'list', '--porcelain')
await expect(ctx.worktrees.restore(agent, { ...created, path: join(link, 'topic') })).rejects.toThrow('configured pool')
expect(await readdir(outside)).toEqual([])
expect(await git(root, 'worktree', 'list', '--porcelain')).toBe(before)
})
it('does not remove a registration when its directory reappears during restoration validation', async () => {
const root = await repo()
ctx = await harness(root)
const agent = testAgent(ctx, root)
const created = await ctx.worktrees.create(agent, { name: 'topic' })
await rm(created.path, { recursive: true, force: true })
const resolve = ctx.fs.resolve.bind(ctx.fs)
const target = await resolve(created.path)
let targetReads = 0
const probe = vi.spyOn(ctx.fs, 'resolve').mockImplementation(async (path, options) => {
const resolved = await resolve(path, options)
if (resolved.targetKey === target.targetKey && ++targetReads === 2) await mkdir(created.path)
return resolved
})
try {
await expect(ctx.worktrees.restore(agent, created)).rejects.toThrow('worktree path already exists')
expect(targetReads).toBe(2)
expect(await git(root, 'worktree', 'list', '--porcelain')).toContain(`worktree ${created.path.split(sep).join('/')}`)
} finally { probe.mockRestore() }
})