/** * @local/git-worktree — Git worktree isolation for DeepSeek Harness (DSH). * Provides agent tools for managing Git worktrees to allow isolated parallel sessions, * subagents, and branches without collisions in the main workspace directory. */ import path from 'node:path' import { spawn } from 'node:child_process' export const name = 'git-worktree' export const inject = ['tools'] /** Run git command in a directory and return { code, stdout, stderr } */ export async function gitExec(cwd, args, { signal, timeoutMs = 30000 } = {}) { return new Promise((resolve) => { let settled = false const done = (res) => { if (settled) return settled = true resolve(res) } const timer = setTimeout(() => { try { proc.kill('SIGTERM') } catch {} done({ code: 124, stdout: '', stderr: 'git command timed out' }) }, timeoutMs) const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], }) if (signal) { signal.addEventListener('abort', () => { clearTimeout(timer) try { proc.kill('SIGKILL') } catch {} done({ code: 130, stdout: '', stderr: 'aborted' }) }, { once: true }) } let stdout = '' let stderr = '' proc.stdout.on('data', (d) => { stdout += d.toString('utf8') }) proc.stderr.on('data', (d) => { stderr += d.toString('utf8') }) proc.on('error', (err) => { clearTimeout(timer) done({ code: 1, stdout: '', stderr: String(err?.message ?? err) }) }) proc.on('close', (code) => { clearTimeout(timer) done({ code: code ?? 0, stdout, stderr }) }) }) } /** Check if directory is inside a git repo and return top-level root */ export async function getGitRoot(cwd) { const res = await gitExec(cwd, ['rev-parse', '--show-toplevel']) if (res.code === 0) return res.stdout.trim() return null } /** Parse `git worktree list --porcelain` */ export function parseWorktreePorcelain(text) { const lines = text.split(/\r?\n/) const worktrees = [] let cur = null for (const line of lines) { if (!line.trim()) { if (cur) worktrees.push(cur) cur = null continue } const [key, ...rest] = line.split(' ') const val = rest.join(' ') if (key === 'worktree') { if (cur) worktrees.push(cur) cur = { path: val, commit: '', branch: '', bare: false, detached: false } } else if (cur) { if (key === 'HEAD') cur.commit = val else if (key === 'branch') cur.branch = val.replace(/^refs\/heads\//, '') else if (key === 'bare') cur.bare = true else if (key === 'detached') cur.detached = true } } if (cur) worktrees.push(cur) return worktrees } export function defineTool(def, fn) { return { name: def.name, description: def.description, parameters: { type: 'object', properties: def.properties, required: def.required ?? [], additionalProperties: false, }, output: { schema: {}, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }], }, timeoutMs: def.timeoutMs ?? 60000, async execute(args, exec) { try { return JSON.parse(JSON.stringify((await fn(args ?? {}, exec)) ?? null)) } catch (err) { return { error: String(err?.message ?? err) } } }, } } export function apply(ctx, config = {}) { const cfg = { worktreesDir: '.worktrees', ...config, } function getCwd(exec) { return exec.agent?.session?.header?.cwd || exec.cwd || process.cwd() } // Tool 1: worktree_list const toolList = defineTool({ name: 'worktree_list', description: 'List all Git worktrees in the current repository with their paths, branches, and commit hashes.', properties: { repo_path: { type: 'string', description: 'Optional path inside the repository. Defaults to current working directory.', }, }, }, async (args, exec) => { const cwd = args.repo_path || getCwd(exec) const root = await getGitRoot(cwd) if (!root) throw new Error(`${cwd} is not inside a git repository`) const res = await gitExec(root, ['worktree', 'list', '--porcelain'], { signal: exec.signal }) if (res.code !== 0) throw new Error(res.stderr.trim() || 'Failed to list worktrees') const worktrees = parseWorktreePorcelain(res.stdout) return { repoRoot: root, worktrees, } }) // Tool 2: worktree_create const toolCreate = defineTool({ name: 'worktree_create', description: 'Create an isolated Git worktree on a new or existing branch. Ideal for isolating subagents, parallel runs, or background experimentation.', properties: { branch: { type: 'string', description: 'Name of the branch to checkout or create.', }, name: { type: 'string', description: 'Subdirectory name for the worktree inside .worktrees/ (defaults to branch name).', }, base: { type: 'string', description: 'Base commit or branch to start from (e.g. "HEAD", "main"). Defaults to current HEAD.', }, repo_path: { type: 'string', description: 'Optional path inside repository. Defaults to current working directory.', }, }, required: ['branch'], }, async (args, exec) => { const cwd = args.repo_path || getCwd(exec) const root = await getGitRoot(cwd) if (!root) throw new Error(`${cwd} is not inside a git repository`) const wtName = args.name || args.branch.replace(/[^a-zA-Z0-9._-]/g, '-') const targetPath = path.resolve(root, cfg.worktreesDir, wtName) // Check if branch already exists const branchCheck = await gitExec(root, ['show-ref', '--verify', `refs/heads/${args.branch}`], { signal: exec.signal }) const branchExists = branchCheck.code === 0 const gitArgs = ['worktree', 'add'] if (!branchExists) { gitArgs.push('-b', args.branch, targetPath) if (args.base) gitArgs.push(args.base) } else { gitArgs.push(targetPath, args.branch) } const res = await gitExec(root, gitArgs, { signal: exec.signal }) if (res.code !== 0) { throw new Error(res.stderr.trim() || `Failed to create worktree at ${targetPath}`) } return { success: true, path: targetPath, branch: args.branch, message: `Worktree successfully created at ${targetPath} on branch ${args.branch}`, } }) // Tool 3: worktree_remove const toolRemove = defineTool({ name: 'worktree_remove', description: 'Remove an isolated Git worktree and clean up its tracking metadata.', properties: { path: { type: 'string', description: 'Path of the worktree to remove (or relative path inside .worktrees/).', }, force: { type: 'boolean', description: 'Force removal even if the worktree contains uncommitted changes.', }, delete_branch: { type: 'boolean', description: 'Whether to also delete the associated branch.', }, repo_path: { type: 'string', description: 'Optional repository path.', }, }, required: ['path'], }, async (args, exec) => { const cwd = args.repo_path || getCwd(exec) const root = await getGitRoot(cwd) if (!root) throw new Error(`${cwd} is not inside a git repository`) let targetPath = args.path if (!path.isAbsolute(targetPath)) { targetPath = path.resolve(root, cfg.worktreesDir, targetPath) } // Inspect worktree before removal to know its branch const listRes = await gitExec(root, ['worktree', 'list', '--porcelain'], { signal: exec.signal }) const list = parseWorktreePorcelain(listRes.stdout) const match = list.find((w) => path.resolve(w.path) === path.resolve(targetPath)) const branchToDelete = (args.delete_branch && match && match.branch) ? match.branch : null const gitArgs = ['worktree', 'remove'] if (args.force) gitArgs.push('--force') gitArgs.push(targetPath) const res = await gitExec(root, gitArgs, { signal: exec.signal }) if (res.code !== 0) { throw new Error(res.stderr.trim() || `Failed to remove worktree at ${targetPath}`) } let branchRemoved = false if (branchToDelete) { const bRes = await gitExec(root, ['branch', args.force ? '-D' : '-d', branchToDelete], { signal: exec.signal }) branchRemoved = (bRes.code === 0) } return { success: true, removedPath: targetPath, branchRemoved: branchRemoved ? branchToDelete : null, message: `Worktree at ${targetPath} removed successfully.`, } }) // Tool 4: worktree_merge const toolMerge = defineTool({ name: 'worktree_merge', description: 'Merge an isolated worktree branch back into the main or target branch.', properties: { branch: { type: 'string', description: 'Source worktree branch to merge from.', }, target_branch: { type: 'string', description: 'Target branch to merge into (defaults to current branch).', }, strategy: { type: 'string', description: 'Merge strategy or mode: "merge" (default), "squash", or "rebase".', }, repo_path: { type: 'string', description: 'Optional repository path.', }, }, required: ['branch'], }, async (args, exec) => { const cwd = args.repo_path || getCwd(exec) const root = await getGitRoot(cwd) if (!root) throw new Error(`${cwd} is not inside a git repository`) // Determine current/target branch let target = args.target_branch if (!target) { const bRes = await gitExec(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { signal: exec.signal }) if (bRes.code === 0) target = bRes.stdout.trim() else target = 'main' } const strat = args.strategy || 'merge' const gitArgs = ['merge'] if (strat === 'squash') gitArgs.push('--squash') gitArgs.push(args.branch) const res = await gitExec(root, gitArgs, { signal: exec.signal }) if (res.code !== 0) { throw new Error(res.stderr.trim() || `Failed to merge branch ${args.branch} into ${target}`) } return { success: true, mergedBranch: args.branch, intoBranch: target, output: res.stdout.trim(), } }) // Register all tools with the tools service ctx.tools?.register(toolList) ctx.tools?.register(toolCreate) ctx.tools?.register(toolRemove) ctx.tools?.register(toolMerge) }