330 lines
10 KiB
JavaScript
330 lines
10 KiB
JavaScript
/**
|
|
* @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)
|
|
}
|