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