Files
deepseek-harness/packages/experimental/worktree/src/index.ts
T
Turtle b0414b8a98 fix(worktree): disable lazy fetch through GIT_NO_LAZY_FETCH
--no-lazy-fetch exists only in Git 2.45+, so older Git exited 129 before
creating a worktree. The environment variable is also honored by the
2.39.4, 2.40.2, 2.41.1, 2.42.2, 2.43.4, and 2.44.1 maintenance releases.
2026-10-10 14:17:21 +08:00

179 lines
9.0 KiB
TypeScript

/** Creates a new Git branch and checkout, then changes the calling Session's working directory. */
import { randomUUID } from 'node:crypto'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-working-directory'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { PREPARE_DIRECTORY } from './directory.ts'
import { runCommand } from './process.ts'
import type { ProcessConfig } from './process.ts'
import type { CreatedWorktree, CreateWorktreeRequest } from './types.ts'
export type { CreatedWorktree, CreateWorktreeRequest } from './types.ts'
/** Worktree location, generated names, executables, and subprocess resource limits. */
export interface Config {
/** Relative checkout pool inside the source repository. */
directory?: string
/** Prefix for automatically generated branch and checkout names. */
namePrefix?: string
/** Executable name or absolute execution-world path of a Git release that honors `GIT_NO_LAZY_FETCH`. */
gitCommand?: string
/** Node executable used for sandboxed directory allocation. */
nodeCommand?: string
/** Deadline in milliseconds for one command. */
timeoutMs?: number
/** Subprocess termination grace in milliseconds. */
graceMs?: number
/** Maximum captured bytes per subprocess output stream. */
maxOutputBytes?: number
}
type ResolvedConfig = Required<Config> & ProcessConfig
interface WorktreeSpec {
branch: string
revision: string
}
declare module '@deepseek-ai/cordis' {
interface Context {
worktrees: WorktreeService
}
}
/** Reject path traversal independently of Git's branch-name grammar. */
function relativePath(value: string): boolean {
return value.length > 0 && !value.includes('\\') && !value.includes(':')
&& value.split('/').every(part => part.length > 0 && part !== '.' && part !== '..')
}
/** Creates retained Git worktrees under the mounted filesystem and sandbox providers. */
export class WorktreeService extends Service {
static inject = ['workingDirectory', 'fs', 'subprocess', 'sandbox', 'sandboxPolicy']
static Config: z<Config> = z.object({
directory: z.string().default('.agents/worktrees'),
namePrefix: z.string().default('worktree-'),
gitCommand: z.string().default('git'),
nodeCommand: z.string().default('node'),
timeoutMs: z.number().default(60_000),
graceMs: z.number().default(3_000),
maxOutputBytes: z.number().default(65_536),
})
private readonly config: ResolvedConfig
private readonly lifetime = new AbortController()
private readonly pending = new Set<Promise<CreatedWorktree>>()
constructor(ctx: Context, config: Config) {
super(ctx, 'worktrees')
this.config = config as ResolvedConfig
if (!relativePath(this.config.directory)) throw new Error('worktree: directory must be a relative directory without dot segments')
if (!relativePath(`${this.config.namePrefix}name`) || this.config.namePrefix.startsWith('-')) {
throw new Error('worktree: namePrefix must produce a relative branch name')
}
for (const key of ['gitCommand', 'nodeCommand'] as const) {
if (this.config[key].trim().length === 0) throw new Error(`worktree: ${key} must not be empty`)
}
for (const key of ['timeoutMs', 'graceMs', 'maxOutputBytes'] as const) {
const value = this.config[key]
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`worktree: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
ctx.effect(() => async () => {
this.lifetime.abort(new Error('worktree service disposed'))
await Promise.allSettled(this.pending)
})
}
/**
* Create a fresh branch and checkout at a pinned local revision, then enter it.
* Existing branches or checkout paths fail. Uncommitted files stay in the source checkout.
* Checkout disables configured clean, smudge, and process filters without changing Git config.
* Repository-local replacement refs still apply; baseCommit reports the resolved object name.
* The new checkout becomes current only after Git setup succeeds. Failures may retain newly
* allocated Git/filesystem artifacts; no branch or checkout is removed automatically.
* @param agent - caller whose current directory selects the source repository and file policy.
* @param request - optional new name and local revision; defaults are generated name and HEAD.
* @param signal - cancellation of lookup, creation, and working-directory publication.
* @returns canonical checkout path, branch name, pinned commit, and source repository root.
*/
async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise<CreatedWorktree> {
const operationSignal = signal === undefined ? this.lifetime.signal : AbortSignal.any([this.lifetime.signal, signal])
operationSignal.throwIfAborted()
const operation = this.createCheckout(agent, this.resolve(request), operationSignal)
this.pending.add(operation)
try {
return await operation
} finally {
this.pending.delete(operation)
}
}
private resolve(request: CreateWorktreeRequest): WorktreeSpec {
const branch = request.name ?? `${this.config.namePrefix}${randomUUID()}`
if (!relativePath(branch) || branch.startsWith('-')) throw new Error('worktree name must be a relative Git branch name')
const revision = request.from ?? 'HEAD'
if (revision.length === 0) throw new Error('worktree from must name a local commit, branch, or tag')
return { branch, revision }
}
private async createCheckout(agent: Agent, { branch, revision }: WorktreeSpec, signal: AbortSignal): Promise<CreatedWorktree> {
const cwd = await this.ctx.workingDirectory.ensure(agent, signal)
const policy = this.ctx.sandboxPolicy.resolve({ session: agent.session })
const run = (directory: string, args: string[], allowedExitCodes?: number[], env?: NodeJS.ProcessEnv) => runCommand(
this.ctx, this.config, policy, directory,
[this.config.gitCommand, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args],
signal, allowedExitCodes, env,
)
await run(cwd, ['check-ref-format', `refs/heads/${branch}`])
const rootOutput = await run(cwd, ['rev-parse', '--show-toplevel'])
const rootTarget = await this.ctx.fs.resolve(rootOutput.stdout.replace(/\r?\n$/, ''), { cwd, signal })
const repositoryRoot = this.ctx.fs.processPath(rootTarget)
const base = await run(repositoryRoot, ['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`])
const baseCommit = base.stdout.trim()
const existing = await run(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], [0, 1])
if (existing.exitCode === 0) throw new Error(`worktree branch already exists: ${branch}`)
const pool = await this.ctx.fs.resolve(this.config.directory, { cwd: repositoryRoot, signal })
const poolPath = this.ctx.fs.processPath(pool)
const destination = await this.ctx.fs.resolve(branch, { cwd: poolPath, signal })
const path = this.ctx.fs.processPath(destination)
if (await this.ctx.fs.lstat(path, undefined, signal) !== undefined) throw new Error(`worktree path already exists: ${path}`)
try {
await runCommand(this.ctx, this.config, policy, repositoryRoot,
[this.config.nodeCommand, '--input-type=module', '-e', PREPARE_DIRECTORY, poolPath, path], signal)
await run(repositoryRoot, ['--work-tree', repositoryRoot, 'worktree', 'add', '--no-checkout', '-b', branch, '--', path, baseCommit])
const configured = await run(path, ['config', '--null', '--name-only', '--get-regexp', '^filter\\.'], [0, 1])
const drivers = new Set<string>()
for (const key of configured.stdout.split('\0')) {
const separator = key.lastIndexOf('.')
if (separator > 'filter.'.length && ['clean', 'smudge', 'process', 'required'].includes(key.slice(separator + 1))) {
drivers.add(key.slice('filter.'.length, separator))
}
}
const overrides: NodeJS.ProcessEnv = { GIT_CONFIG_COUNT: String(drivers.size * 4) }
let index = 0
for (const driver of drivers) {
for (const attribute of ['clean', 'smudge', 'process', 'required']) {
overrides[`GIT_CONFIG_KEY_${index}`] = `filter.${driver}.${attribute}`
overrides[`GIT_CONFIG_VALUE_${index}`] = attribute === 'required' ? 'false' : ''
index++
}
}
await run(path, ['--work-tree', path, 'reset', '--hard', '--no-recurse-submodules', baseCommit], undefined, overrides)
const canonicalPath = await this.ctx.workingDirectory.set(agent, path, signal)
return { path: canonicalPath, branch, baseCommit, repositoryRoot }
} catch (error) {
throw new Error(`Could not finish worktree ${branch} at ${path}. Any created checkout and branch are retained. ${error instanceof Error ? error.message : String(error)}`, { cause: error })
}
}
}
export default WorktreeService