From b20e297851429ccd47e0538170c8a98e7e921498 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 11:46:31 +0200 Subject: [PATCH] feat: add removeWorktreeForJob helper Removes worktree dir via `git worktree remove --force` and deletes the local branch by default; keepBranch=true preserves the branch. Returns { removed: false } when the worktree path doesn't exist. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/git/worktree.test.ts | 92 +++++++++++++++++++++++++++++++++- src/git/worktree.ts | 40 +++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts index 0594fd1..d92ee00 100644 --- a/__tests__/git/worktree.test.ts +++ b/__tests__/git/worktree.test.ts @@ -4,7 +4,7 @@ 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' +import { createWorktreeForJob, removeWorktreeForJob } from '../../src/git/worktree.js' const exec = promisify(execFile) @@ -112,3 +112,93 @@ describe('createWorktreeForJob', () => { ).rejects.toThrow('Worktree path already exists') }) }) + +describe('removeWorktreeForJob', () => { + 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('removes worktree directory and deletes branch by default', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const { worktreePath, branchName } = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-rm-01', + branchName: 'feat/job-rm-01', + baseRef: 'origin/main', + }) + + const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-01' }) + + expect(result.removed).toBe(true) + await expect(fs.access(worktreePath)).rejects.toThrow() + await expect(fs.access(path.join(wtParent, 'job-rm-01'))).rejects.toThrow() + + // Branch should be deleted + await expect( + exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { + cwd: repoDir, + }), + ).rejects.toThrow() + }) + + it('removes worktree directory but keeps branch when keepBranch=true', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + const wtParent = await makeWorktreeParent() + + const { worktreePath, branchName } = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-rm-02', + branchName: 'feat/job-rm-02', + baseRef: 'origin/main', + }) + + const result = await removeWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-rm-02', + keepBranch: true, + }) + + expect(result.removed).toBe(true) + await expect(fs.access(worktreePath)).rejects.toThrow() + await expect(fs.access(path.join(wtParent, 'job-rm-02'))).rejects.toThrow() + + // Branch should still exist + const { stdout } = await exec( + 'git', + ['show-ref', '--verify', `refs/heads/${branchName}`], + { cwd: repoDir }, + ) + expect(stdout).toContain(branchName) + }) + + it('returns { removed: false } when worktree does not exist', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-nonexistent' }) + + expect(result.removed).toBe(false) + }) +}) diff --git a/src/git/worktree.ts b/src/git/worktree.ts index f3aa68e..dd5e26d 100644 --- a/src/git/worktree.ts +++ b/src/git/worktree.ts @@ -55,3 +55,43 @@ export async function createWorktreeForJob(opts: { return { worktreePath, branchName } } + +export async function removeWorktreeForJob(opts: { + repoRoot: string + jobId: string + keepBranch?: boolean +}): Promise<{ removed: boolean }> { + const { repoRoot, jobId, keepBranch = false } = opts + + const parent = + process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? + path.join(os.homedir(), '.scrum4me-agent-worktrees') + + const worktreePath = path.join(parent, jobId) + + try { + await fs.access(worktreePath) + } catch { + return { removed: false } + } + + let branchName: string | undefined + if (!keepBranch) { + try { + const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: worktreePath, + }) + branchName = stdout.trim() + } catch { + // worktree HEAD unreadable — skip branch deletion + } + } + + await exec('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot }) + + if (!keepBranch && branchName && (await branchExists(repoRoot, branchName))) { + await exec('git', ['branch', '-D', branchName], { cwd: repoRoot }) + } + + return { removed: true } +}