From 129d0594fc6f21a7647f634b906d235925e5b8be Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 13 Oct 2026 07:56:46 +0000 Subject: [PATCH] feat: initial commit for git-worktree plugin --- README.md | 12 ++ cordis.patch.yml | 5 + index.js | 329 +++++++++++++++++++++++++++++++++++++++++ package.json | 25 ++++ test/worktree.test.mjs | 63 ++++++++ 5 files changed, 434 insertions(+) create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 package.json create mode 100644 test/worktree.test.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..b73bd44 --- /dev/null +++ b/README.md @@ -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. diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..6218e59 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,5 @@ +- insert: + - id: git-worktree + name: '@local/git-worktree' + config: + worktreesDir: '.worktrees' diff --git a/index.js b/index.js new file mode 100644 index 0000000..13d3943 --- /dev/null +++ b/index.js @@ -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) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f04c295 --- /dev/null +++ b/package.json @@ -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" + } + } +} diff --git a/test/worktree.test.mjs b/test/worktree.test.mjs new file mode 100644 index 0000000..09ff9d6 --- /dev/null +++ b/test/worktree.test.mjs @@ -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 }) +})