feat: initial commit for git-worktree plugin

This commit is contained in:
Agent committed 2026-10-13 07:56:46 +00:00
commit 129d0594fc
5 files changed
+434

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
/**
* @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)
}