From e7bb3c82baf45927c4a3324893a40e1448cdf656 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 11:34:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20createWorktreeForJob=20helper=20?= =?UTF-8?q?=E2=80=94=20isolate=20agent=20per=20job=20in=20git=20worktree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- __tests__/git/worktree.test.ts | 114 +++++++++++++++++++++++++++++++++ src/git/worktree.ts | 57 +++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 __tests__/git/worktree.test.ts create mode 100644 src/git/worktree.ts diff --git a/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts new file mode 100644 index 0000000..0594fd1 --- /dev/null +++ b/__tests__/git/worktree.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import * as os from 'node:os' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { createWorktreeForJob } from '../../src/git/worktree.js' + +const exec = promisify(execFile) + +async function git(args: string[], cwd: string) { + return exec('git', args, { cwd }) +} + +async function setupRepo(): Promise<{ repoDir: string; originDir: string }> { + const base = os.tmpdir() + const originDir = await fs.mkdtemp(path.join(base, 'scrum4me-origin-')) + const repoDir = await fs.mkdtemp(path.join(base, 'scrum4me-repo-')) + + await git(['init', '--bare'], originDir) + await git(['init'], repoDir) + await git(['config', 'user.email', 'test@test.com'], repoDir) + await git(['config', 'user.name', 'Test'], repoDir) + await git(['remote', 'add', 'origin', originDir], repoDir) + await fs.writeFile(path.join(repoDir, 'README.md'), '# test') + await git(['add', '.'], repoDir) + await git(['commit', '-m', 'init'], repoDir) + await git(['push', 'origin', 'HEAD:main'], repoDir) + + return { repoDir, originDir } +} + +describe('createWorktreeForJob', () => { + const tmpDirs: string[] = [] + const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR + + afterEach(async () => { + if (originalWorktreeDir === undefined) { + delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR + } else { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir + } + for (const dir of tmpDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + async function makeWorktreeParent(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-')) + tmpDirs.push(dir) + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir + return dir + } + + it('creates a worktree directory with the correct branch as HEAD', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-001', + branchName: 'feat/job-001', + baseRef: 'origin/main', + }) + + const stat = await fs.stat(result.worktreePath) + expect(stat.isDirectory()).toBe(true) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe('feat/job-001') + + expect(result.branchName).toBe('feat/job-001') + expect(result.worktreePath).toBe(path.join(wtParent, 'job-001')) + }) + + it('suffixes branch name with timestamp when branch already exists', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + await git(['branch', 'feat/job-002'], repoDir) + + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-002', + branchName: 'feat/job-002', + baseRef: 'origin/main', + }) + + expect(result.branchName).toMatch(/^feat\/job-002-\d+$/) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe(result.branchName) + }) + + it('rejects when worktree path already exists', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const existingPath = path.join(wtParent, 'job-003') + await fs.mkdir(existingPath) + + await expect( + createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-003', + branchName: 'feat/job-003', + baseRef: 'origin/main', + }), + ).rejects.toThrow('Worktree path already exists') + }) +}) diff --git a/src/git/worktree.ts b/src/git/worktree.ts new file mode 100644 index 0000000..f3aa68e --- /dev/null +++ b/src/git/worktree.ts @@ -0,0 +1,57 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import * as path from 'node:path' +import * as os from 'node:os' +import * as fs from 'node:fs/promises' + +const exec = promisify(execFile) + +async function branchExists(repoRoot: string, name: string): Promise { + try { + await exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { cwd: repoRoot }) + return true + } catch { + return false + } +} + +export async function createWorktreeForJob(opts: { + repoRoot: string + jobId: string + branchName: string + baseRef?: string +}): Promise<{ worktreePath: string; branchName: string }> { + const { repoRoot, jobId, baseRef = 'origin/main' } = opts + let { branchName } = opts + + const parent = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + + await fs.mkdir(parent, { recursive: true }) + + const worktreePath = path.join(parent, jobId) + + // Reject if worktree path already exists — caller must remove it first + try { + await fs.access(worktreePath) + throw new Error( + `Worktree path already exists: ${worktreePath}. Call removeWorktreeForJob first.`, + ) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + + await exec('git', ['fetch', 'origin', '--prune'], { cwd: repoRoot }) + + // Suffix with timestamp when branch already exists + if (await branchExists(repoRoot, branchName)) { + branchName = `${branchName}-${Date.now()}` + } + + await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], { + cwd: repoRoot, + }) + + return { worktreePath, branchName } +}