feat: initial commit for git-worktree plugin
This commit is contained in:
commit
129d0594fc
5 files changed
+434
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# @local/git-worktree
|
||||
|
||||
Git worktree isolation for DeepSeek Harness (DSH).
|
||||
|
||||
Provides agent tools for managing Git worktrees, enabling isolated execution environments for parallel tasks, subagents, and risk-free experimentation without colliding in the main repository working directory.
|
||||
|
||||
## Tools Provided
|
||||
|
||||
- `worktree_list`: List all active Git worktrees with branches and commit heads.
|
||||
- `worktree_create`: Create an isolated Git worktree on a new or existing branch inside `.worktrees/`.
|
||||
- `worktree_remove`: Remove an isolated worktree and optionally delete the branch.
|
||||
- `worktree_merge`: Merge or squash changes from an isolated worktree branch back into the target branch.
|
||||
@@ -0,0 +1,5 @@
|
||||
- insert:
|
||||
- id: git-worktree
|
||||
name: '@local/git-worktree'
|
||||
config:
|
||||
worktreesDir: '.worktrees'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@local/git-worktree",
|
||||
"version": "1.0.0",
|
||||
"description": "Git worktree isolation for DSH: create isolated worktrees for parallel agents, branches, or sessions",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"lib/*.js",
|
||||
"cordis.patch.yml"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import {
|
||||
parseWorktreePorcelain,
|
||||
gitExec,
|
||||
getGitRoot,
|
||||
} from '../index.js'
|
||||
|
||||
test('parseWorktreePorcelain parses git porcelain worktree format', () => {
|
||||
const sample = `worktree /home/user/project
|
||||
HEAD 1234567890abcdef
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /home/user/project/.worktrees/feature-a
|
||||
HEAD fedcba0987654321
|
||||
branch refs/heads/feature-a
|
||||
|
||||
worktree /home/user/project/.worktrees/detached-run
|
||||
HEAD aaaaaaa000000000
|
||||
detached
|
||||
`
|
||||
const res = parseWorktreePorcelain(sample)
|
||||
assert.equal(res.length, 3)
|
||||
assert.equal(res[0].path, '/home/user/project')
|
||||
assert.equal(res[0].branch, 'main')
|
||||
assert.equal(res[0].commit, '1234567890abcdef')
|
||||
assert.equal(res[1].branch, 'feature-a')
|
||||
assert.equal(res[2].detached, true)
|
||||
})
|
||||
|
||||
test('gitExec and getGitRoot inside real git repo', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git-wt-test-'))
|
||||
await gitExec(tmp, ['init', '-b', 'main'])
|
||||
await gitExec(tmp, ['config', 'user.name', 'Test Agent'])
|
||||
await gitExec(tmp, ['config', 'user.email', 'test@example.com'])
|
||||
fs.writeFileSync(path.join(tmp, 'hello.txt'), 'hello world')
|
||||
await gitExec(tmp, ['add', 'hello.txt'])
|
||||
await gitExec(tmp, ['commit', '-m', 'initial commit'])
|
||||
|
||||
const root = await getGitRoot(tmp)
|
||||
// Resolve symlinks if any on temporary dirs
|
||||
assert.equal(fs.realpathSync(root), fs.realpathSync(tmp))
|
||||
|
||||
// Create worktree
|
||||
const wtDir = path.join(tmp, '.worktrees', 'test-wt')
|
||||
const addRes = await gitExec(tmp, ['worktree', 'add', '-b', 'feature-test', wtDir])
|
||||
assert.equal(addRes.code, 0)
|
||||
|
||||
// Verify list
|
||||
const listRes = await gitExec(tmp, ['worktree', 'list', '--porcelain'])
|
||||
const parsed = parseWorktreePorcelain(listRes.stdout)
|
||||
assert.equal(parsed.length, 2)
|
||||
assert.ok(parsed.some((w) => w.branch === 'feature-test'))
|
||||
|
||||
// Remove worktree
|
||||
const rmRes = await gitExec(tmp, ['worktree', 'remove', wtDir])
|
||||
assert.equal(rmRes.code, 0)
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
Reference in new issue
Block a user